# ============================================================================
# [LIVRE] ANALYSE DE DONNÉES AVEC PYTHON - GUIDE ULTRA-DÉTAILLÉ POUR DÉBUTANTS
# ============================================================================
#
# [OBJECTIF] GUIDE COMPLET : DE ZÉRO À EXPERT EN ANALYSE DE DONNÉES
#
# Ce guide est organisé en 6 parties progressives :
#
# PARTIE 1 : FONDAMENTAUX (partie1_fondamentaux.txt)
#   - Chapitre 0 : Introduction à l'Analyse de Données
#   - Chapitre 1 : Python pour la Data Science (Rappels Essentiels)
#   - Chapitre 2 : NumPy - Fondements du Calcul Numérique
#   - Chapitre 3 : Pandas - Manipulation de Données
#
# PARTIE 2 : EXPLORATION ET NETTOYAGE (partie2_exploration.txt)
#   - Chapitre 4 : Exploration de Données (EDA)
#   - Chapitre 5 : Nettoyage et Préparation des Données
#   - Chapitre 6 : Transformation et Ingénierie des Features
#
# PARTIE 3 : VISUALISATION (partie3_visualisation.txt)
#   - Chapitre 7 : Matplotlib - Visualisation de Base
#   - Chapitre 8 : Seaborn - Visualisation Statistique
#   - Chapitre 9 : Plotly - Visualisation Interactive
#   - Chapitre 10 : Pandas Visualisation Intégrée
#
# PARTIE 4 : STATISTIQUES ET ANALYSE (partie4_statistiques.txt)
#   - Chapitre 11 : Statistiques Descriptives
#   - Chapitre 12 : Statistiques Inférentielles
#   - Chapitre 13 : Corrélation et Régression
#   - Chapitre 14 : Analyse Temporelle (Time Series)
#
# PARTIE 5 : MACHINE LEARNING (partie5_machine_learning.txt)
#   - Chapitre 15 : Introduction au Machine Learning
#   - Chapitre 16 : Scikit-learn - Fondamentaux
#   - Chapitre 17 : Modèles de Classification
#   - Chapitre 18 : Modèles de Régression
#   - Chapitre 19 : Clustering et Réduction de Dimensions
#   - Chapitre 20 : Évaluation et Optimisation des Modèles
#
# PARTIE 6 : PROJETS ET PRODUCTION (partie6_projets.txt)
#   - Chapitre 21 : Pipelines de Données
#   - Chapitre 22 : Bases de Données et SQL avec Python
#   - Chapitre 23 : APIs et Web Scraping
#   - Chapitre 24 : Rapports Automatisés
#   - Chapitre 25 : Best Practices et Projet Final
#
# [TEMPS] TEMPS DE LECTURE TOTAL : ~35-40 heures
# [DOCS] PRÉREQUIS : Python de base (variables, fonctions, listes)
# ============================================================================


# ============================================================================
# [GUIDE] CHAPITRE 0 : INTRODUCTION À L'ANALYSE DE DONNÉES
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Ce qu'est l'analyse de données
[OK] Pourquoi Python est le meilleur outil
[OK] L'écosystème Data Science Python
[OK] Comment configurer votre environnement
[OK] Le cycle complet d'un projet d'analyse
"""


# ----------------------------------------------------------------------------
# [REFLEXION] QU'EST-CE QUE L'ANALYSE DE DONNÉES ?
# ----------------------------------------------------------------------------

"""
DÉFINITION SIMPLE

L'analyse de données est le processus d'examiner, nettoyer, transformer
et modéliser des données dans le but de découvrir des informations utiles,
tirer des conclusions et soutenir la prise de décisions.

[IDEE] ANALOGIE SIMPLE [RECHERCHE]

Imaginez que vous avez 10 000 tickets de caisse d'un supermarché.
L'analyse de données vous permet de :
- Trouver les produits les plus vendus
- Identifier les heures de pointe
- Prédire les ventes de demain
- Comprendre le comportement des clients

Sans outil -> Impossible (trop de données)
Avec Python -> Quelques lignes de code !


LE CYCLE DE L'ANALYSE DE DONNÉES

1. COLLECTE DES DONNÉES [ENTREE]
   - Bases de données, fichiers CSV/Excel
   - APIs, Web scraping
   - Capteurs, logs

2. NETTOYAGE DES DONNÉES [NETTOYAGE]
   - Valeurs manquantes
   - Doublons
   - Erreurs de format

3. EXPLORATION (EDA) [RECHERCHE]
   - Statistiques descriptives
   - Visualisations
   - Patterns et anomalies

4. ANALYSE APPROFONDIE [GRAPHIQUE]
   - Statistiques inférentielles
   - Corrélations
   - Modèles

5. INTERPRÉTATION [IDEE]
   - Conclusions
   - Recommandations

6. COMMUNICATION [ANNONCE]
   - Rapports
   - Dashboards
   - Présentations


TYPES D'ANALYSE

1. DESCRIPTIVE -> Que s'est-il passé ?
   "Les ventes ont augmenté de 15% en janvier"

2. DIAGNOSTIQUE -> Pourquoi cela s'est-il passé ?
   "Les ventes ont augmenté grâce aux promotions"

3. PRÉDICTIVE -> Que va-t-il se passer ?
   "Les ventes devraient augmenter de 20% le mois prochain"

4. PRESCRIPTIVE -> Que doit-on faire ?
   "Augmenter le stock de 20% avant le mois prochain"
"""


# ----------------------------------------------------------------------------
# [PYTHON] POURQUOI PYTHON POUR LA DATA SCIENCE ?
# ----------------------------------------------------------------------------

"""
COMPARAISON DES OUTILS

┌───────────────┬─────────────┬───────────┬──────────────┐
│  Critère      │   Python    │     R     │   Excel      │
├───────────────┼─────────────┼───────────┼──────────────┤
│ Facilité      │   Très      │  Moyenne  │   Facile     │
│               │   Facile    │           │              │
│ ML/IA         │   *****   │   ****   │   *         │
│ Visualisation │   *****   │   ****   │   **        │
│ Big Data      │   *****   │   ***     │   *         │
│ Automatisation│   *****   │   ***     │   **        │
│ Communauté    │   Énorme    │  Grande   │   Grande     │
│ Gratuit       │   [OK]        │   [OK]      │   [X]         │
│ Polyvalent    │   [OK]        │   [X]      │   [X]         │
└───────────────┴─────────────┴───────────┴──────────────┘

AVANTAGES PYTHON [PYTHON]

[OK] POLYVALENT
   - Data Science ET développement web ET automatisation
   - Un seul langage pour tout

[OK] BIBLIOTHÈQUES RICHES
   - NumPy, Pandas, Matplotlib, Scikit-learn
   - Mises à jour constantes

[OK] COMMUNAUTÉ MASSIVE
   - Millions de développeurs
   - StackOverflow, GitHub, tutoriels

[OK] LISIBLE ET SIMPLE
   - Syntaxe proche du langage humain
   - Facile à apprendre

[OK] OPEN SOURCE
   - Gratuit
   - Transparent

[OK] ML ET IA
   - TensorFlow, PyTorch, Scikit-learn
   - État de l'art du Machine Learning
"""


# ----------------------------------------------------------------------------
# [PACKAGE] L'ÉCOSYSTÈME DATA SCIENCE PYTHON
# ----------------------------------------------------------------------------

"""
LES BIBLIOTHÈQUES ESSENTIELLES

COUCHE 1 : FONDATIONS
────────────────────
NumPy
  - Calcul numérique
  - Tableaux multidimensionnels
  - Opérations mathématiques rapides
  -> "La brique de base de tout"

Pandas
  - Manipulation de données tabulaires
  - DataFrames et Series
  - Lecture/écriture de fichiers
  -> "Excel en Python, mais en 1000x plus puissant"


COUCHE 2 : VISUALISATION
────────────────────────
Matplotlib
  - Visualisation de base
  - Contrôle total
  - Nombreux types de graphiques
  -> "Le couteau suisse de la visualisation"

Seaborn
  - Visualisation statistique
  - Graphiques élégants
  - Basé sur Matplotlib
  -> "Matplotlib en plus beau et plus simple"

Plotly
  - Visualisation interactive
  - Graphiques dans le navigateur
  - Dashboards
  -> "Pour des graphiques qui bougent"


COUCHE 3 : STATISTIQUES
────────────────────────
SciPy
  - Statistiques avancées
  - Tests statistiques
  - Optimisation
  -> "Le mathématicien de Python"

Statsmodels
  - Modèles statistiques
  - Régression avancée
  - Tests statistiques
  -> "Pour les statisticiens sérieux"


COUCHE 4 : MACHINE LEARNING
───────────────────────────
Scikit-learn
  - ML classique
  - Classification, régression, clustering
  - Pipeline ML
  -> "Le couteau suisse du ML"

TensorFlow / Keras
  - Deep Learning
  - Réseaux de neurones
  - GPU computing
  -> "Pour l'intelligence artificielle"

PyTorch
  - Deep Learning
  - Recherche en IA
  - Flexible
  -> "Le favori des chercheurs"


COUCHE 5 : UTILITAIRES
──────────────────────
Jupyter Notebook / JupyterLab
  - Environnement interactif
  - Code + Documentation + Visualisations
  -> "L'environnement idéal pour la data science"

SQLAlchemy
  - Connexion bases de données
  -> "Pour parler aux bases de données"

Requests + BeautifulSoup
  - Web scraping
  -> "Pour collecter des données du web"
"""


# ----------------------------------------------------------------------------
# [OUTILS] CONFIGURATION DE L'ENVIRONNEMENT
# ----------------------------------------------------------------------------

"""
MÉTHODE 1 : ANACONDA (RECOMMANDÉE POUR DÉBUTANTS)

Anaconda = Distribution Python spécialisée Data Science
- Installe Python + 250+ packages d'un coup
- Jupyter Notebook inclus
- Gestionnaire d'environnements (conda)

INSTALLATION :
1. Télécharger : https://www.anaconda.com/products/distribution
2. Installer (cocher "Add to PATH")
3. Vérifier : conda --version


CRÉER ENVIRONNEMENT CONDA
"""

# Terminal
conda create -n data_science python=3.10
conda activate data_science

"""
MÉTHODE 2 : PIP + VENV (Pour les puristes)
"""

# Créer environnement virtuel
python -m venv data_env
source data_env/bin/activate  # Mac/Linux
data_env\Scripts\activate      # Windows

"""
INSTALLER LES PACKAGES
"""

# Installation complète
pip install numpy pandas matplotlib seaborn plotly scikit-learn scipy statsmodels jupyter

# Ou avec conda
conda install numpy pandas matplotlib seaborn plotly scikit-learn scipy statsmodels jupyter

"""
VÉRIFIER L'INSTALLATION
"""

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

print(f"NumPy version: {np.__version__}")
print(f"Pandas version: {pd.__version__}")
print("[OK] Installation réussie !")

"""
LANCER JUPYTER NOTEBOOK
"""

jupyter notebook

# Ouvre automatiquement le navigateur à http://localhost:8888
# Interface pour écrire et exécuter du code interactif


# ----------------------------------------------------------------------------
# [CARNET] JUPYTER NOTEBOOK : GUIDE COMPLET
# ----------------------------------------------------------------------------

"""
QU'EST-CE QUE JUPYTER NOTEBOOK ?

Jupyter = Environnement interactif où vous pouvez :
- Écrire et exécuter du code Python
- Ajouter du texte formaté (Markdown)
- Afficher des graphiques inline
- Partager facilement (format .ipynb)

[IDEE] C'est le standard de la data science !


TYPES DE CELLULES

1. CODE -> Code Python exécutable
2. MARKDOWN -> Texte, titres, formules mathématiques
3. RAW -> Texte brut sans formatage


RACCOURCIS CLAVIER ESSENTIELS

MODE COMMANDE (appuyer sur Échap) :
Ctrl+Enter  -> Exécuter cellule
Shift+Enter -> Exécuter et aller à suivante
Alt+Enter   -> Exécuter et créer nouvelle cellule
A           -> Insérer cellule AU-DESSUS
B           -> Insérer cellule EN-DESSOUS
D + D       -> Supprimer cellule
M           -> Mode Markdown
Y           -> Mode Code
Z           -> Annuler

MODE ÉDITION (cliquer dans cellule) :
Tab         -> Autocomplétion
Shift+Tab   -> Documentation
Ctrl+Z      -> Annuler
Ctrl+/      -> Commenter


TRUCS ET ASTUCES JUPYTER
"""

# Afficher documentation
help(pd.DataFrame)  # Option 1
?pd.DataFrame        # Option 2

# Mesurer le temps d'exécution
%timeit sum(range(1000))
%%timeit  # Pour toute la cellule

# Variables actuelles
%whos

# Lister fonctions d'un module
dir(pd)

# Afficher graphiques inline
# (En haut du notebook)
%matplotlib inline

# Afficher graphiques interactifs
%matplotlib widget


# ============================================================================
# [GUIDE] CHAPITRE 1 : PYTHON POUR LA DATA SCIENCE - RAPPELS ESSENTIELS
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Maîtriser les structures de données Python pour la data
[OK] Utiliser les compréhensions de liste efficacement
[OK] Fonctions lambda et map/filter
[OK] Gestion des fichiers
[OK] Bonnes pratiques de code
"""


# ----------------------------------------------------------------------------
# [PACKAGE] STRUCTURES DE DONNÉES ESSENTIELLES
# ----------------------------------------------------------------------------

"""
1. LISTES -> Données ordonnées et modifiables
"""

# Créer listes
notes = [15, 17, 12, 18, 14, 16]
noms = ["Alice", "Bob", "Charlie"]
mixte = [1, "deux", 3.0, True, None]

# Opérations essentielles pour data science
print(len(notes))           # Longueur : 6
print(min(notes))           # Minimum : 12
print(max(notes))           # Maximum : 18
print(sum(notes))           # Somme : 92
print(sum(notes)/len(notes))# Moyenne : 15.33

# Slicing (découpage)
print(notes[0])     # Premier : 15
print(notes[-1])    # Dernier : 16
print(notes[1:4])   # De 1 à 3 : [17, 12, 18]
print(notes[::2])   # Un sur deux : [15, 12, 14]
print(notes[::-1])  # Inverser : [16, 14, 18, 12, 17, 15]

# Méthodes utiles
notes.append(20)           # Ajouter en fin
notes.extend([11, 13])     # Ajouter liste
notes.sort()               # Trier en place
sorted_notes = sorted(notes)  # Trier sans modifier
notes.remove(11)           # Supprimer valeur

"""
2. DICTIONNAIRES -> Données clé-valeur (comme JSON)
"""

# Création
etudiant = {
    "nom": "Alice",
    "age": 22,
    "notes": [15, 17, 12],
    "ville": "Paris"
}

# Accès
print(etudiant["nom"])              # Alice
print(etudiant.get("age", 0))       # 22 (avec valeur défaut)
print(etudiant.get("email", "N/A")) # N/A (clé absente)

# Modifier
etudiant["email"] = "alice@example.com"  # Ajouter
etudiant["age"] = 23                      # Modifier
del etudiant["ville"]                     # Supprimer

# Itérer
for cle, valeur in etudiant.items():
    print(f"{cle}: {valeur}")

# Méthodes
print(etudiant.keys())    # Clés
print(etudiant.values())  # Valeurs
print(etudiant.items())   # Paires

# Dict de dicts (structure courante en data)
etudiants = {
    "A001": {"nom": "Alice", "note": 15},
    "A002": {"nom": "Bob", "note": 17},
    "A003": {"nom": "Charlie", "note": 12}
}

"""
3. TUPLES -> Données immuables
"""

point = (3.5, 7.2)
coordonnees = (48.8566, 2.3522, "Paris")

# Déballage (unpacking)
x, y = point
lat, lon, ville = coordonnees

# Tuples dans listes (courant pour données)
donnees = [
    ("Alice", 15, "Paris"),
    ("Bob", 17, "Lyon"),
    ("Charlie", 12, "Marseille")
]

for nom, note, ville in donnees:
    print(f"{nom} a eu {note}/20 à {ville}")

"""
4. SETS -> Valeurs uniques
"""

donnees_brutes = [1, 2, 2, 3, 3, 3, 4]
uniques = set(donnees_brutes)  # {1, 2, 3, 4}

# Opérations ensemblistes (utiles en data)
ensemble_a = {1, 2, 3, 4, 5}
ensemble_b = {3, 4, 5, 6, 7}

print(ensemble_a & ensemble_b)  # Intersection : {3, 4, 5}
print(ensemble_a | ensemble_b)  # Union : {1,2,3,4,5,6,7}
print(ensemble_a - ensemble_b)  # Différence : {1, 2}


# ----------------------------------------------------------------------------
# [RAPIDE] COMPRÉHENSIONS DE LISTE
# ----------------------------------------------------------------------------

"""
[IDEE] POURQUOI ?

Les compréhensions remplacent les boucles for longues
-> Plus rapides ET plus lisibles
-> Standard en data science
"""

# Exemple basique
notes = [15, 17, 12, 18, 14, 16]

# [X] Style débutant
doubles = []
for n in notes:
    doubles.append(n * 2)

# [OK] Style data science
doubles = [n * 2 for n in notes]
print(doubles)  # [30, 34, 24, 36, 28, 32]

# Avec condition (filtre)
bonnes_notes = [n for n in notes if n >= 15]
print(bonnes_notes)  # [15, 17, 18, 16]

# Transformation + filtre
mentions = [f"Mention {n}" for n in notes if n >= 14]

# Compréhension de dict
note_mentions = {nom: note for nom, note in zip(noms, notes)}

# Compréhension de set
uniques = {n % 5 for n in notes}

# Compréhension imbriquée
matrice = [[i * j for j in range(1, 4)] for i in range(1, 4)]
# [[1, 2, 3], [2, 4, 6], [3, 6, 9]]


# ----------------------------------------------------------------------------
# [OUTIL] FONCTIONS ESSENTIELLES
# ----------------------------------------------------------------------------

"""
FONCTIONS LAMBDA (anonymes)
"""

# Fonction normale
def doubler(x):
    return x * 2

# Équivalent lambda
doubler = lambda x: x * 2
ajouter = lambda x, y: x + y

# Utilisation avec sorted()
etudiants = [("Alice", 15), ("Bob", 17), ("Charlie", 12)]

# Trier par note (2ème élément)
tries_par_note = sorted(etudiants, key=lambda e: e[1])
print(tries_par_note)
# [('Charlie', 12), ('Alice', 15), ('Bob', 17)]

# Trier par note décroissante
tries_desc = sorted(etudiants, key=lambda e: e[1], reverse=True)

"""
MAP, FILTER, REDUCE
"""

notes = [15, 17, 12, 18, 14, 16]

# map() -> Appliquer fonction à chaque élément
sur_20 = list(map(lambda n: n/20*100, notes))
# [75.0, 85.0, 60.0, 90.0, 70.0, 80.0]

# filter() -> Filtrer selon condition
bonnes = list(filter(lambda n: n >= 15, notes))
# [15, 17, 18, 16]

# reduce() -> Réduire à une valeur
from functools import reduce
somme = reduce(lambda acc, n: acc + n, notes)  # 92

"""
[IDEE] En pratique, les compréhensions sont préférées à map/filter
Mais map/filter sont utiles à connaître
"""


# ----------------------------------------------------------------------------
# [DOSSIER] GESTION DES FICHIERS
# ----------------------------------------------------------------------------

"""
LIRE ET ÉCRIRE DES FICHIERS CSV
"""

# Écrire CSV
import csv

donnees = [
    ["nom", "age", "note"],
    ["Alice", 22, 15],
    ["Bob", 23, 17],
    ["Charlie", 21, 12]
]

with open("etudiants.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.writer(f)
    writer.writerows(donnees)

# Lire CSV
with open("etudiants.csv", "r", encoding="utf-8") as f:
    reader = csv.DictReader(f)
    for ligne in reader:
        print(ligne)
        # {'nom': 'Alice', 'age': '22', 'note': '15'}

"""
LIRE ET ÉCRIRE JSON
"""

import json

# Écrire JSON
data = {
    "etudiants": [
        {"nom": "Alice", "note": 15},
        {"nom": "Bob", "note": 17}
    ]
}

with open("data.json", "w", encoding="utf-8") as f:
    json.dump(data, f, indent=2, ensure_ascii=False)

# Lire JSON
with open("data.json", "r", encoding="utf-8") as f:
    data_loaded = json.load(f)

print(data_loaded["etudiants"][0]["nom"])  # Alice

"""
GESTION DES CHEMINS (pathlib - Recommandé)
"""

from pathlib import Path

# Créer chemin
data_dir = Path("data")
data_dir.mkdir(exist_ok=True)

# Construire chemins
fichier = data_dir / "resultats.csv"

# Vérifications
print(fichier.exists())    # True/False
print(fichier.suffix)      # .csv
print(fichier.stem)        # resultats
print(fichier.parent)      # data

# Lister fichiers
for csv_file in data_dir.glob("*.csv"):
    print(csv_file)


# ----------------------------------------------------------------------------
# [OBJECTIF] PROGRAMMATION ORIENTÉE OBJET POUR LA DATA SCIENCE
# ----------------------------------------------------------------------------

"""
[IDEE] POURQUOI POO EN DATA SCIENCE ?

- Créer des pipelines réutilisables
- Organiser des workflows complexes
- Comprendre les bibliothèques (Scikit-learn utilise des classes)
"""

class AnalyseurDonnees:
    """Classe pour analyser un jeu de données simple"""

    def __init__(self, donnees):
        """
        Initialiser avec une liste de nombres

        COMMENT : Appel automatique à la création
        POURQUOI : Initialiser l'état de l'objet
        QUAND : À chaque création d'instance
        """
        self.donnees = donnees
        self.n = len(donnees)

    def moyenne(self):
        """Calculer la moyenne"""
        return sum(self.donnees) / self.n

    def variance(self):
        """Calculer la variance"""
        moy = self.moyenne()
        return sum((x - moy) ** 2 for x in self.donnees) / self.n

    def ecart_type(self):
        """Calculer l'écart-type"""
        return self.variance() ** 0.5

    def resume(self):
        """Afficher résumé statistique"""
        print(f"N observations : {self.n}")
        print(f"Minimum       : {min(self.donnees)}")
        print(f"Maximum       : {max(self.donnees)}")
        print(f"Moyenne       : {self.moyenne():.2f}")
        print(f"Écart-type    : {self.ecart_type():.2f}")

# Utilisation
notes = [15, 17, 12, 18, 14, 16, 13, 19, 11, 16]
analyseur = AnalyseurDonnees(notes)
analyseur.resume()


# ============================================================================
# [GUIDE] CHAPITRE 2 : NUMPY - FONDEMENTS DU CALCUL NUMÉRIQUE
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Créer et manipuler des arrays NumPy
[OK] Comprendre les dimensions (1D, 2D, 3D)
[OK] Opérations vectorisées vs boucles
[OK] Indexing et slicing avancés
[OK] Opérations mathématiques et statistiques
[OK] Génération de données aléatoires
"""

import numpy as np

# ----------------------------------------------------------------------------
# [NOMBRE] QU'EST-CE QUE NUMPY ET POURQUOI ?
# ----------------------------------------------------------------------------

"""
PROBLÈME AVEC LES LISTES PYTHON
"""

# Liste Python
liste = [1, 2, 3, 4, 5]

# Multiplier par 2 -> ERREUR avec les listes
# liste * 2 -> [1,2,3,4,5,1,2,3,4,5] (répétition, pas multiplication !)

# Il faut une boucle :
liste_double = [x * 2 for x in liste]  # [2, 4, 6, 8, 10]

"""
SOLUTION NUMPY : VECTORISATION
"""

array = np.array([1, 2, 3, 4, 5])
array_double = array * 2  # [2, 4, 6, 8, 10] -> Direct !

"""
[IDEE] POURQUOI NUMPY EST PLUS RAPIDE ?

1. STOCKAGE CONTINU EN MÉMOIRE
   - Liste Python : Pointeurs vers objets (indirect)
   - NumPy array : Valeurs côte à côte (direct)

2. OPÉRATIONS EN C/FORTRAN
   - Code NumPy compilé (ultra-rapide)
   - Pas d'overhead Python

3. VECTORISATION
   - Opération sur tout le tableau d'un coup
   - Pas de boucle Python (lente)


COMPARAISON DE PERFORMANCE :
"""

import time

taille = 1_000_000

# Liste Python
liste = list(range(taille))
debut = time.time()
resultat_liste = [x ** 2 for x in liste]
temps_liste = time.time() - debut

# NumPy
array = np.arange(taille)
debut = time.time()
resultat_numpy = array ** 2
temps_numpy = time.time() - debut

print(f"Liste Python : {temps_liste:.3f} secondes")
print(f"NumPy Array  : {temps_numpy:.3f} secondes")
print(f"NumPy est {temps_liste/temps_numpy:.0f}x plus rapide !")
# NumPy est souvent 10-100x plus rapide


# ----------------------------------------------------------------------------
# [OUTIL] CRÉER DES ARRAYS NUMPY
# ----------------------------------------------------------------------------

"""
MÉTHODE 1 : À partir de listes
"""

# 1D (vecteur)
v1 = np.array([1, 2, 3, 4, 5])
print(v1)         # [1 2 3 4 5]
print(type(v1))   # <class 'numpy.ndarray'>
print(v1.dtype)   # int64

# 2D (matrice)
m1 = np.array([[1, 2, 3],
               [4, 5, 6],
               [7, 8, 9]])
print(m1)
# [[1 2 3]
#  [4 5 6]
#  [7 8 9]]

# 3D (tenseur)
t1 = np.array([[[1, 2], [3, 4]],
               [[5, 6], [7, 8]]])
print(t1.shape)   # (2, 2, 2)

"""
MÉTHODE 2 : Fonctions de création
"""

# Zéros
np.zeros(5)              # [0. 0. 0. 0. 0.]
np.zeros((3, 4))         # Matrice 3x4 de zéros

# Uns
np.ones(5)               # [1. 1. 1. 1. 1.]
np.ones((2, 3))          # Matrice 2x3 de uns

# Valeur constante
np.full(5, 7)            # [7 7 7 7 7]
np.full((3, 3), 0.5)     # Matrice 3x3 de 0.5

# Séquences
np.arange(0, 10, 2)      # [0 2 4 6 8] (start, stop, step)
np.linspace(0, 1, 11)    # [0.0, 0.1, ..., 1.0] (start, stop, n_points)

# Matrice identité
np.eye(3)
# [[1. 0. 0.]
#  [0. 1. 0.]
#  [0. 0. 1.]]

# Valeurs aléatoires
np.random.seed(42)        # Pour reproductibilité [ATTENTION] TOUJOURS faire ça
np.random.rand(5)         # Uniformes [0, 1]
np.random.randn(5)        # Normales (µ=0, σ=1)
np.random.randint(0, 10, 5)  # Entiers [0, 10)


# ----------------------------------------------------------------------------
# [MESURE] PROPRIÉTÉS D'UN ARRAY
# ----------------------------------------------------------------------------

m = np.array([[1, 2, 3, 4],
              [5, 6, 7, 8],
              [9, 10, 11, 12]])

print(m.shape)    # (3, 4) -> 3 lignes, 4 colonnes
print(m.ndim)     # 2 -> nombre de dimensions
print(m.size)     # 12 -> nombre total d'éléments
print(m.dtype)    # int64 -> type des données
print(m.nbytes)   # 96 -> taille en octets

"""
TYPES DE DONNÉES (dtype)

int8, int16, int32, int64   -> Entiers
float16, float32, float64   -> Décimaux
bool                        -> Booléens
complex64, complex128       -> Complexes
str_                        -> Chaînes

[IDEE] POURQUOI LE TYPE COMPTE ?

int64 -> 8 octets par élément
float32 -> 4 octets par élément (2x moins de mémoire)

Pour grands datasets : utiliser float32 économise de la mémoire !
"""

# Spécifier le type
arr_int = np.array([1, 2, 3], dtype=np.int32)
arr_float = np.array([1, 2, 3], dtype=np.float64)
arr_bool = np.array([True, False, True])

# Convertir le type
converti = arr_int.astype(np.float64)


# ----------------------------------------------------------------------------
# [OBJECTIF] INDEXING ET SLICING
# ----------------------------------------------------------------------------

"""
ARRAY 1D
"""

v = np.array([10, 20, 30, 40, 50])

print(v[0])      # 10 -> premier
print(v[-1])     # 50 -> dernier
print(v[1:4])    # [20 30 40]
print(v[::2])    # [10 30 50] -> un sur deux
print(v[::-1])   # [50 40 30 20 10] -> inversé

"""
ARRAY 2D
"""

m = np.array([[1, 2, 3],
              [4, 5, 6],
              [7, 8, 9]])

# Élément individuel
print(m[0, 0])    # 1 -> ligne 0, colonne 0
print(m[1, 2])    # 6 -> ligne 1, colonne 2
print(m[-1, -1])  # 9 -> dernière ligne, dernière colonne

# Lignes et colonnes
print(m[0, :])    # [1 2 3] -> toute la ligne 0
print(m[:, 1])    # [2 5 8] -> toute la colonne 1
print(m[1:3, 1:3])  # Sous-matrice

"""
INDEXING BOOLÉEN (Très utilisé !)

[IDEE] C'est comme un filtre sur vos données !
"""

notes = np.array([15, 17, 12, 18, 14, 16, 11, 19])

# Créer masque booléen
masque = notes >= 15
print(masque)  # [True True False True False True False True]

# Appliquer masque
bonnes_notes = notes[masque]
print(bonnes_notes)  # [15 17 18 16 19]

# En une ligne
bonnes_notes = notes[notes >= 15]

# Conditions multiples
excellentes = notes[(notes >= 16) & (notes <= 18)]
print(excellentes)  # [17 18 16]

# Modifier avec masque
notes_copy = notes.copy()
notes_copy[notes_copy < 10] = 10  # Mettre un plancher à 10

"""
FANCY INDEXING (sélection par indices)
"""

m = np.array([[1, 2, 3],
              [4, 5, 6],
              [7, 8, 9]])

indices = [0, 2]           # Lignes 0 et 2
print(m[indices])          # [[1 2 3], [7 8 9]]

print(m[[0, 2], [1, 0]])   # m[0,1] et m[2,0] -> [2, 7]


# ----------------------------------------------------------------------------
# [RAPIDE] OPÉRATIONS VECTORISÉES
# ----------------------------------------------------------------------------

"""
OPÉRATIONS ARITHMÉTIQUES
"""

a = np.array([1, 2, 3, 4, 5])
b = np.array([10, 20, 30, 40, 50])

print(a + b)     # [11 22 33 44 55]
print(a - b)     # [-9 -18 -27 -36 -45]
print(a * b)     # [10 40 90 160 250]
print(a / b)     # [0.1 0.1 0.1 0.1 0.1]
print(a ** 2)    # [1 4 9 16 25]
print(np.sqrt(a))# [1. 1.41 1.73 2. 2.24]

# Scalaire (broadcast)
print(a + 10)    # [11 12 13 14 15]
print(a * 3)     # [3 6 9 12 15]

"""
BROADCASTING : Opérations entre arrays de formes différentes

[IDEE] ANALOGIE :
Imaginez que le scalaire (ou petit tableau) se "copie"
pour matcher la taille du grand tableau
"""

m = np.array([[1, 2, 3],
              [4, 5, 6]])

# Ajouter vecteur à matrice
v = np.array([10, 20, 30])
resultat = m + v
# [[11 22 33]
#  [14 25 36]]

"""
FONCTIONS MATHÉMATIQUES
"""

x = np.array([0, np.pi/6, np.pi/4, np.pi/3, np.pi/2])

np.sin(x)      # Sinus
np.cos(x)      # Cosinus
np.tan(x)      # Tangente
np.exp(x)      # Exponentielle
np.log(x + 1)  # Logarithme naturel
np.log10(x + 1)# Logarithme base 10
np.abs(x)      # Valeur absolue
np.round(x, 2) # Arrondir


# ----------------------------------------------------------------------------
# [GRAPHIQUE] STATISTIQUES AVEC NUMPY
# ----------------------------------------------------------------------------

donnees = np.array([23, 45, 12, 67, 34, 89, 11, 56, 78, 42])

# Statistiques de base
print(f"Somme    : {np.sum(donnees)}")
print(f"Moyenne  : {np.mean(donnees):.2f}")
print(f"Médiane  : {np.median(donnees):.2f}")
print(f"Min      : {np.min(donnees)}")
print(f"Max      : {np.max(donnees)}")
print(f"Écart-type: {np.std(donnees):.2f}")
print(f"Variance : {np.var(donnees):.2f}")
print(f"Quantile 25%: {np.percentile(donnees, 25):.2f}")
print(f"Quantile 75%: {np.percentile(donnees, 75):.2f}")

# Sur axes (pour matrices)
m = np.array([[1, 2, 3],
              [4, 5, 6],
              [7, 8, 9]])

print(np.sum(m, axis=0))   # Somme par colonne : [12 15 18]
print(np.sum(m, axis=1))   # Somme par ligne   : [6 15 24]
print(np.mean(m, axis=0))  # Moyenne par colonne

"""
FONCTIONS D'INDEX
"""

donnees = np.array([23, 45, 12, 67, 34, 89, 11, 56])

print(np.argmin(donnees))  # Index du minimum : 6
print(np.argmax(donnees))  # Index du maximum : 5

# Trier
print(np.sort(donnees))        # Tableau trié
print(np.argsort(donnees))     # Indices qui trieraient


# ----------------------------------------------------------------------------
# [SYNC] MANIPULATION DES SHAPES
# ----------------------------------------------------------------------------

"""
RESHAPE : Changer la forme
"""

v = np.arange(12)      # [0 1 2 3 4 5 6 7 8 9 10 11]

m = v.reshape(3, 4)    # Matrice 3x4
print(m)
# [[ 0  1  2  3]
#  [ 4  5  6  7]
#  [ 8  9 10 11]]

m3 = v.reshape(2, 2, 3) # Tenseur 2x2x3

# -1 : NumPy calcule automatiquement
m_auto = v.reshape(-1, 4)  # NumPy calcule lignes : 3
m_auto2 = v.reshape(3, -1) # NumPy calcule colonnes : 4

"""
FLATTEN ET RAVEL : Aplatir en 1D
"""

m = np.array([[1, 2, 3], [4, 5, 6]])
print(m.flatten())   # [1 2 3 4 5 6] (copie)
print(m.ravel())     # [1 2 3 4 5 6] (vue si possible)

"""
TRANSPOSER
"""

m = np.array([[1, 2, 3], [4, 5, 6]])
print(m.T)           # Transposée (3x2)
print(m.shape)       # (2, 3)
print(m.T.shape)     # (3, 2)

"""
EMPILER ET CONCATÉNER
"""

a = np.array([1, 2, 3])
b = np.array([4, 5, 6])

print(np.concatenate([a, b]))     # [1 2 3 4 5 6]
print(np.vstack([a, b]))          # Empiler verticalement
print(np.hstack([a, b]))          # Empiler horizontalement

# Pour matrices
m1 = np.array([[1, 2], [3, 4]])
m2 = np.array([[5, 6], [7, 8]])

np.vstack([m1, m2])   # Empiler (4x2)
np.hstack([m1, m2])   # Côte à côte (2x4)
np.dstack([m1, m2])   # En profondeur (2x2x2)


# ----------------------------------------------------------------------------
# [GAME_DIE] GÉNÉRATION DE DONNÉES ALÉATOIRES
# ----------------------------------------------------------------------------

"""
[IDEE] TOUJOURS FIXER LE SEED !

POURQUOI : Pour que les résultats soient reproductibles
           Sinon, chaque exécution donne des résultats différents
"""

np.random.seed(42)   # Fixer pour reproductibilité

# Distributions
np.random.rand(5)           # Uniforme [0, 1)
np.random.uniform(0, 10, 5) # Uniforme [0, 10)
np.random.randn(5)          # Normale (µ=0, σ=1)
np.random.normal(100, 15, 5) # Normale (µ=100, σ=15)
np.random.randint(1, 7, 10) # Entiers [1, 7)
np.random.binomial(10, 0.5, 5) # Binomiale
np.random.poisson(5, 10)     # Poisson

# Permutation et choix
arr = np.arange(10)
np.random.shuffle(arr)       # Mélanger en place
np.random.permutation(arr)   # Mélanger (copie)
np.random.choice(arr, 5)     # Échantillonnage sans remise
np.random.choice(arr, 5, replace=True)  # Avec remise

# Nouveau générateur (recommandé Python 3.7+)
rng = np.random.default_rng(seed=42)
rng.random(5)
rng.integers(0, 10, 5)
rng.normal(0, 1, 5)


# ============================================================================
# [GUIDE] CHAPITRE 3 : PANDAS - MANIPULATION DE DONNÉES
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Créer et comprendre DataFrame et Series
[OK] Lire/écrire différents formats de fichiers
[OK] Sélectionner et filtrer des données
[OK] Modifier et ajouter des colonnes
[OK] Trier, trier et agréger
[OK] Groupby : analyser par groupes
[OK] Merge et Join : combiner des DataFrames
"""

import pandas as pd
import numpy as np

# ----------------------------------------------------------------------------
# [CONSTRUCTION] DATAFRAME ET SERIES : LES STRUCTURES DE BASE
# ----------------------------------------------------------------------------

"""
SERIES = Colonne unique de données (1D)
DATAFRAME = Tableau (2D) = Collection de Series

ANALOGIE :
- Series  -> Une colonne d'un tableau Excel
- DataFrame -> Un tableau Excel complet
"""

"""
CRÉER UNE SERIES
"""

# À partir d'une liste
s1 = pd.Series([15, 17, 12, 18, 14])
print(s1)
# 0    15
# 1    17
# 2    12
# 3    18
# 4    14
# dtype: int64

# Avec index personnalisé
notes_s = pd.Series(
    [15, 17, 12, 18, 14],
    index=["Alice", "Bob", "Charlie", "David", "Emma"]
)
print(notes_s["Alice"])   # 15
print(notes_s[notes_s >= 15])  # Alice et Bob et David

# À partir d'un dictionnaire
ages_s = pd.Series({"Alice": 22, "Bob": 23, "Charlie": 21})

"""
CRÉER UN DATAFRAME
"""

# Méthode 1 : Dict de listes
df = pd.DataFrame({
    "nom": ["Alice", "Bob", "Charlie", "David", "Emma"],
    "age": [22, 23, 21, 24, 22],
    "note": [15, 17, 12, 18, 14],
    "ville": ["Paris", "Lyon", "Paris", "Marseille", "Lyon"]
})
print(df)
#       nom  age  note      ville
# 0   Alice   22    15      Paris
# 1     Bob   23    17       Lyon
# 2 Charlie   21    12      Paris
# 3   David   24    18  Marseille
# 4    Emma   22    14       Lyon

# Méthode 2 : Liste de listes
df2 = pd.DataFrame(
    [["Alice", 22, 15], ["Bob", 23, 17]],
    columns=["nom", "age", "note"]
)

# Méthode 3 : Liste de dicts
df3 = pd.DataFrame([
    {"nom": "Alice", "age": 22},
    {"nom": "Bob", "age": 23}
])

# Méthode 4 : À partir de NumPy
arr = np.random.randint(0, 100, size=(5, 3))
df4 = pd.DataFrame(arr, columns=["A", "B", "C"])


# ----------------------------------------------------------------------------
# [ENTREE] LECTURE DE FICHIERS
# ----------------------------------------------------------------------------

"""
CSV (Format le plus courant)
"""

# Lecture basique
df = pd.read_csv("fichier.csv")

# Options importantes
df = pd.read_csv(
    "fichier.csv",
    sep=";",           # Séparateur (défaut: virgule)
    encoding="utf-8",  # Encodage
    header=0,          # Ligne d'en-tête (0 = première)
    index_col=0,       # Colonne comme index
    usecols=["col1", "col2"],  # Seulement ces colonnes
    nrows=1000,        # Seulement N lignes
    skiprows=[1, 2],   # Ignorer ces lignes
    na_values=["N/A", "?", ""],  # Valeurs considérées NaN
    parse_dates=["date"],  # Convertir en dates
    dtype={"age": int, "note": float}  # Spécifier types
)

"""
EXCEL
"""

df = pd.read_excel("fichier.xlsx")
df = pd.read_excel("fichier.xlsx", sheet_name="Feuille1")
df = pd.read_excel("fichier.xlsx", sheet_name=0)  # Première feuille

# Lire toutes les feuilles
toutes_feuilles = pd.read_excel("fichier.xlsx", sheet_name=None)
# Retourne un dict {nom_feuille: DataFrame}

"""
JSON
"""

df = pd.read_json("fichier.json")
df = pd.read_json("https://api.example.com/data")  # Depuis URL

"""
SQL
"""

import sqlite3
conn = sqlite3.connect("base.db")
df = pd.read_sql("SELECT * FROM etudiants", conn)
conn.close()

"""
ÉCRITURE DE FICHIERS
"""

# CSV
df.to_csv("resultat.csv", index=False, encoding="utf-8")
df.to_csv("resultat.csv", sep=";")  # Point-virgule

# Excel
df.to_excel("resultat.xlsx", index=False, sheet_name="Résultats")

# Plusieurs feuilles Excel
with pd.ExcelWriter("rapport.xlsx") as writer:
    df1.to_excel(writer, sheet_name="Données")
    df2.to_excel(writer, sheet_name="Résumé")

# JSON
df.to_json("resultat.json", orient="records")


# ----------------------------------------------------------------------------
# [RECHERCHE] EXPLORATION INITIALE DU DATAFRAME
# ----------------------------------------------------------------------------

"""
PREMIÈRE CHOSE À FAIRE AVEC UN NOUVEAU DATASET !
"""

df = pd.DataFrame({
    "nom": ["Alice", "Bob", "Charlie", "David", "Emma", "Frank"],
    "age": [22, 23, 21, 24, 22, None],
    "note": [15, 17, 12, 18, 14, 16],
    "ville": ["Paris", "Lyon", "Paris", "Marseille", "Lyon", "Paris"],
    "actif": [True, True, False, True, False, True]
})

# Aperçu
print(df.head())       # 5 premières lignes (défaut)
print(df.head(3))      # 3 premières lignes
print(df.tail(3))      # 3 dernières lignes
print(df.sample(3))    # 3 lignes aléatoires

# Structure
print(df.shape)        # (6, 5) -> 6 lignes, 5 colonnes
print(df.dtypes)       # Types de chaque colonne
print(df.info())       # Résumé complet avec mémoire
print(df.columns)      # Noms des colonnes
print(df.index)        # Index

# Statistiques
print(df.describe())   # Stats sur colonnes numériques
print(df.describe(include="all"))  # Toutes colonnes
print(df.nunique())    # Valeurs uniques par colonne
print(df.value_counts())  # Fréquence des valeurs

"""
EXAMPLE SORTIE de df.describe() :

        age       note
count   5.000000   6.000000
mean    22.400000  15.333333
std      1.140175   2.160247
min     21.000000  12.000000
25%     22.000000  14.000000
50%     22.000000  15.500000
75%     23.000000  16.750000
max     24.000000  18.000000

[IDEE] INTERPRÉTATION :
count  -> Nombre de valeurs non-nulles
mean   -> Moyenne
std    -> Écart-type
min    -> Minimum
25%    -> 1er quartile
50%    -> Médiane
75%    -> 3ème quartile
max    -> Maximum
"""


# ----------------------------------------------------------------------------
# [OBJECTIF] SÉLECTION DE DONNÉES
# ----------------------------------------------------------------------------

"""
SÉLECTIONNER DES COLONNES
"""

# Une colonne -> Series
noms = df["nom"]
noms = df.nom          # Équivalent (si pas d'espace dans le nom)

# Plusieurs colonnes -> DataFrame
selection = df[["nom", "note"]]
selection = df[["nom", "age", "ville"]]

"""
SÉLECTIONNER DES LIGNES

.loc -> Par LABEL (nom de l'index)
.iloc -> Par POSITION (numéro entier)
"""

# Avec index numérique par défaut
df_l = df.copy()

# loc : Par label
print(df_l.loc[0])          # Ligne 0
print(df_l.loc[0:3])        # Lignes 0 à 3 INCLUS
print(df_l.loc[[0, 2, 4]])  # Lignes 0, 2, 4

# iloc : Par position
print(df_l.iloc[0])          # Première ligne
print(df_l.iloc[0:3])        # 3 premières (0, 1, 2 - exclusif !)
print(df_l.iloc[-1])         # Dernière ligne
print(df_l.iloc[[0, 2, 4]])  # Positions 0, 2, 4

# Lignes ET colonnes
print(df_l.loc[0, "nom"])              # Ligne 0, colonne "nom"
print(df_l.loc[0:2, ["nom", "note"]])  # Lignes 0-2, colonnes sélectionnées
print(df_l.iloc[0:3, 0:2])            # 3 premières lignes, 2 premières colonnes

"""
FILTRAGE CONDITIONNEL

[IDEE] C'est la feature la plus puissante de Pandas !
"""

# Condition simple
adultes = df[df["age"] >= 22]
parisiens = df[df["ville"] == "Paris"]
bonnes_notes = df[df["note"] >= 15]

# Conditions multiples
# [ATTENTION] Utiliser & (et) | (ou) ~ (non) avec parenthèses !
adultes_paris = df[(df["age"] >= 22) & (df["ville"] == "Paris")]
non_actifs = df[~df["actif"]]
paris_ou_lyon = df[(df["ville"] == "Paris") | (df["ville"] == "Lyon")]

# isin() -> Valeur dans une liste
grandes_villes = df[df["ville"].isin(["Paris", "Lyon", "Marseille"])]

# between() -> Entre deux valeurs
notes_moyennes = df[df["note"].between(13, 16)]

# str.contains() -> Contient une chaîne
# df[df["nom"].str.contains("Al")]

# query() -> Syntaxe SQL-like
bonnes = df.query("note >= 15 and ville == 'Paris'")
bonnes2 = df.query("age >= 22 & actif == True")

"""
TRUCS ET ASTUCES SÉLECTION
"""

# Vérifier si colonne contient valeur
"Alice" in df["nom"].values   # True

# Index de la première occurrence
df["nom"].tolist().index("Alice")  # 0

# where() -> Remplacer si condition fausse
df["note"].where(df["note"] >= 10, other=10)  # Note minimum 10

# mask() -> Remplacer si condition vraie
df["note"].mask(df["note"] > 18, other=18)   # Note maximum 18


# ----------------------------------------------------------------------------
# [EDIT] MODIFIER UN DATAFRAME
# ----------------------------------------------------------------------------

"""
AJOUTER OU MODIFIER DES COLONNES
"""

df_m = df.copy()  # Toujours travailler sur une copie !

# Ajouter colonne simple
df_m["mention"] = "Passable"  # Valeur constante

# Ajouter colonne calculée
df_m["note_sur_100"] = df_m["note"] / 20 * 100

# Colonne conditionnelle avec np.where()
df_m["reussi"] = np.where(df_m["note"] >= 10, "Oui", "Non")

# Colonne avec conditions multiples (np.select)
conditions = [
    df_m["note"] >= 16,
    df_m["note"] >= 14,
    df_m["note"] >= 10
]
valeurs = ["Très Bien", "Bien", "Passable"]
df_m["mention"] = np.select(conditions, valeurs, default="Insuffisant")

# Colonne avec apply()
def calculer_mention(note):
    if note >= 16:
        return "Très Bien"
    elif note >= 14:
        return "Bien"
    elif note >= 10:
        return "Passable"
    else:
        return "Insuffisant"

df_m["mention_apply"] = df_m["note"].apply(calculer_mention)

# apply() avec lambda
df_m["nom_upper"] = df_m["nom"].apply(lambda x: x.upper())

"""
RENOMMER DES COLONNES
"""

df_r = df.copy()

# Renommer certaines colonnes
df_r = df_r.rename(columns={
    "nom": "prenom",
    "note": "score",
    "ville": "commune"
})

# Renommer toutes les colonnes
df_r.columns = ["prenom", "annees", "score", "commune", "inscrit"]

"""
SUPPRIMER DES COLONNES ET LIGNES
"""

df_s = df.copy()

# Supprimer colonnes
df_s = df_s.drop(columns=["actif"])
df_s = df_s.drop(columns=["age", "ville"])

# Supprimer lignes par index
df_s = df_s.drop(index=0)
df_s = df_s.drop(index=[0, 2, 4])

# Supprimer lignes selon condition
df_s = df_s[df_s["note"] >= 10]  # Garder seulement notes >= 10

"""
RÉINDEXER
"""

df_r = df.reset_index(drop=True)  # Remettre index 0, 1, 2...
df_r = df.set_index("nom")         # Utiliser nom comme index


# ----------------------------------------------------------------------------
# [GRAPHIQUE] TRI ET AGRÉGATION
# ----------------------------------------------------------------------------

"""
TRI
"""

# Trier par une colonne
df_trie = df.sort_values("note")                    # Croissant
df_trie = df.sort_values("note", ascending=False)   # Décroissant

# Trier par plusieurs colonnes
df_trie = df.sort_values(
    ["ville", "note"],
    ascending=[True, False]  # ville croissant, note décroissant
)

# Trier par index
df_trie = df.sort_index()

"""
AGRÉGATION
"""

# Statistiques globales
print(df["note"].mean())    # Moyenne
print(df["note"].sum())     # Somme
print(df["note"].max())     # Maximum
print(df["note"].min())     # Minimum
print(df["note"].count())   # Nombre de non-nulls
print(df["note"].std())     # Écart-type
print(df["note"].median())  # Médiane

# agg() : Plusieurs statistiques à la fois
stats = df["note"].agg(["mean", "std", "min", "max"])
stats2 = df[["note", "age"]].agg(["mean", "std"])

# describe() : Résumé complet
df["note"].describe()


# ----------------------------------------------------------------------------
# [LIEN] GROUPBY : ANALYSER PAR GROUPES
# ----------------------------------------------------------------------------

"""
[IDEE] GROUPBY = SQL GROUP BY

POURQUOI : Calculer statistiques pour chaque groupe
QUAND : "Quelle est la moyenne par ville ?"
        "Quel est le total par catégorie ?"

COMMENT :

df.groupby("colonne")["autre_colonne"].fonction()
    │                  │                │
    │                  │                └─ Agrégation
    │                  └──────────────── Sur quelle colonne
    └─────────────────────────────────── Grouper par
"""

# Moyenne par ville
moyenne_par_ville = df.groupby("ville")["note"].mean()
print(moyenne_par_ville)
# ville
# Lyon          15.5
# Marseille     18.0
# Paris         13.5
# Name: note, dtype: float64

# Nombre par ville
count_par_ville = df.groupby("ville")["nom"].count()

# Plusieurs statistiques
stats_par_ville = df.groupby("ville")["note"].agg(
    ["mean", "std", "count", "min", "max"]
)

# Grouper par plusieurs colonnes
stats_multi = df.groupby(["ville", "actif"])["note"].mean()

# agg avec dict (différente agrégation par colonne)
stats_avancees = df.groupby("ville").agg({
    "note": ["mean", "std"],
    "age": "mean",
    "nom": "count"
})

# Réinitialiser l'index après groupby
df_groupe = df.groupby("ville")["note"].mean().reset_index()
df_groupe.columns = ["ville", "note_moyenne"]

"""
TRANSFORMATIONS GROUPBY
"""

# Ajouter la moyenne du groupe au DataFrame original
df["note_moy_ville"] = df.groupby("ville")["note"].transform("mean")

# Rang dans le groupe
df["rang_dans_ville"] = df.groupby("ville")["note"].rank(ascending=False)

"""
FILTRAGE SUR GROUPES
"""

# Garder seulement groupes avec au moins 2 membres
df_filtre = df.groupby("ville").filter(lambda x: len(x) >= 2)


# ----------------------------------------------------------------------------
# [LIEN] MERGE ET JOIN : COMBINER DES DATAFRAMES
# ----------------------------------------------------------------------------

"""
[IDEE] ANALOGIE SQL :

pd.merge() ≈ JOIN en SQL
pd.concat() ≈ UNION en SQL
"""

# DataFrames d'exemple
etudiants = pd.DataFrame({
    "id": [1, 2, 3, 4, 5],
    "nom": ["Alice", "Bob", "Charlie", "David", "Emma"],
    "promo": ["A", "B", "A", "C", "B"]
})

notes = pd.DataFrame({
    "id": [1, 2, 3, 4, 6],  # Note: 6 n'existe pas dans etudiants, 5 absent
    "note_math": [15, 17, 12, 18, 14],
    "note_info": [16, 15, 13, 19, 11]
})

"""
INNER JOIN (intersection)
"""

# Seulement les étudiants qui ont des notes ET vice-versa
inner = pd.merge(etudiants, notes, on="id", how="inner")
print(inner)
# id 1, 2, 3, 4 (ni Emma id=5, ni id=6)

"""
LEFT JOIN (tout à gauche)
"""

left = pd.merge(etudiants, notes, on="id", how="left")
# Tous les étudiants, NaN si pas de notes

"""
RIGHT JOIN (tout à droite)
"""

right = pd.merge(etudiants, notes, on="id", how="right")
# Toutes les notes, NaN si étudiant inconnu

"""
OUTER JOIN (union)
"""

outer = pd.merge(etudiants, notes, on="id", how="outer")
# Tout le monde, NaN là où manquant

"""
MERGE SUR PLUSIEURS COLONNES
"""

df_merge = pd.merge(
    df1, df2,
    on=["ville", "annee"],     # Même nom dans les deux
    left_on="nom_a",           # Colonnes de noms différents
    right_on="nom_b"
)

"""
CONCAT : Empiler DataFrames
"""

# Empiler verticalement (ajouter lignes)
df_total = pd.concat([df1, df2, df3], ignore_index=True)

# Empiler horizontalement (ajouter colonnes)
df_total = pd.concat([df1, df2], axis=1)

# Avec clés (pour identifier l'origine)
df_total = pd.concat([df1, df2], keys=["source1", "source2"])


# ============================================================================
# [COURS] EXERCICE PRATIQUE 1 : ANALYSE COMPLÈTE D'UN DATASET
# ============================================================================

"""
OBJECTIF : Créer, analyser et manipuler un dataset d'étudiants

CAHIER DES CHARGES :
1. Créer un DataFrame de 10 étudiants avec :
   - Nom, âge, note_math, note_info, note_physique, ville, année_study
2. Calculer la moyenne générale de chaque étudiant
3. Attribuer les mentions (Très Bien ≥16, Bien ≥14, Passable ≥10, Insuffisant)
4. Trouver les statistiques par ville
5. Trouver les 3 meilleurs étudiants
6. Sauvegarder en CSV

SOLUTION COMPLÈTE :
"""

import numpy as np
import pandas as pd

np.random.seed(42)

# ==========================================
# ÉTAPE 1 : Créer le dataset
# ==========================================
noms = ["Alice", "Bob", "Charlie", "David", "Emma",
        "Frank", "Grace", "Henry", "Iris", "Jack"]

villes = ["Paris", "Lyon", "Marseille", "Paris", "Lyon",
          "Bordeaux", "Paris", "Lyon", "Marseille", "Bordeaux"]

annees = np.random.randint(1, 5, 10)

# Générer notes réalistes
def generer_notes(n, mu, sigma, min_note=0, max_note=20):
    notes = np.random.normal(mu, sigma, n)
    return np.clip(notes, min_note, max_note).round(1)

df = pd.DataFrame({
    "nom": noms,
    "age": np.random.randint(18, 26, 10),
    "ville": villes,
    "annee": annees,
    "note_math": generer_notes(10, 13, 3),
    "note_info": generer_notes(10, 14, 2.5),
    "note_physique": generer_notes(10, 12, 3.5)
})

print("=" * 60)
print("DATASET CRÉÉ :")
print("=" * 60)
print(df.to_string())

# ==========================================
# ÉTAPE 2 : Calculer la moyenne générale
# ==========================================
colonnes_notes = ["note_math", "note_info", "note_physique"]
df["moyenne_generale"] = df[colonnes_notes].mean(axis=1).round(2)

print("\n" + "=" * 60)
print("AVEC MOYENNE GÉNÉRALE :")
print("=" * 60)
print(df[["nom", "note_math", "note_info", "note_physique", "moyenne_generale"]].to_string())

# ==========================================
# ÉTAPE 3 : Attribuer les mentions
# ==========================================
conditions = [
    df["moyenne_generale"] >= 16,
    df["moyenne_generale"] >= 14,
    df["moyenne_generale"] >= 10
]
valeurs = ["Très Bien", "Bien", "Passable"]
df["mention"] = np.select(conditions, valeurs, default="Insuffisant")

print("\n" + "=" * 60)
print("RÉSULTATS AVEC MENTIONS :")
print("=" * 60)
print(df[["nom", "moyenne_generale", "mention"]].to_string())

# ==========================================
# ÉTAPE 4 : Statistiques par ville
# ==========================================
stats_ville = df.groupby("ville").agg(
    nb_etudiants=("nom", "count"),
    moy_generale=("moyenne_generale", "mean"),
    moy_math=("note_math", "mean"),
    moy_info=("note_info", "mean"),
    moy_physique=("note_physique", "mean"),
    meilleure_note=("moyenne_generale", "max"),
    moins_bonne=("moyenne_generale", "min")
).round(2)

print("\n" + "=" * 60)
print("STATISTIQUES PAR VILLE :")
print("=" * 60)
print(stats_ville.to_string())

# ==========================================
# ÉTAPE 5 : Les 3 meilleurs étudiants
# ==========================================
top3 = df.nlargest(3, "moyenne_generale")[["nom", "ville", "moyenne_generale", "mention"]]

print("\n" + "=" * 60)
print("TOP 3 ÉTUDIANTS :")
print("=" * 60)
for rang, (_, etudiant) in enumerate(top3.iterrows(), 1):
    print(f"{rang}. {etudiant['nom']:10} ({etudiant['ville']:12}) - "
          f"{etudiant['moyenne_generale']:.2f}/20 - {etudiant['mention']}")

# ==========================================
# ÉTAPE 6 : Sauvegarder
# ==========================================
df.to_csv("resultats_etudiants.csv", index=False, encoding="utf-8")
stats_ville.to_csv("stats_par_ville.csv", encoding="utf-8")
print("\n[OK] Fichiers sauvegardés !")

# ==========================================
# BONUS : Résumé global
# ==========================================
print("\n" + "=" * 60)
print("RÉSUMÉ GLOBAL :")
print("=" * 60)
print(f"Nombre d'étudiants  : {len(df)}")
print(f"Moyenne générale    : {df['moyenne_generale'].mean():.2f}/20")
print(f"Médiane             : {df['moyenne_generale'].median():.2f}/20")
print(f"Écart-type          : {df['moyenne_generale'].std():.2f}")
print(f"Meilleur étudiant   : {df.loc[df['moyenne_generale'].idxmax(), 'nom']}")
print(f"Moins bon étudiant  : {df.loc[df['moyenne_generale'].idxmin(), 'nom']}")
print("\nRépartition des mentions :")
print(df["mention"].value_counts().to_string())

# ============================================================================
# [DOCS] RÉCAPITULATIF PARTIE 1
# ============================================================================

"""
[BRAVO] FÉLICITATIONS ! PARTIE 1 TERMINÉE !

VOUS MAÎTRISEZ MAINTENANT :

Chapitre 0 : Introduction
[OK] Le cycle de l'analyse de données
[OK] L'écosystème Python pour la data science
[OK] Configuration de l'environnement
[OK] Jupyter Notebook

Chapitre 1 : Python pour la Data
[OK] Listes, dicts, tuples, sets
[OK] Compréhensions de liste
[OK] Fonctions lambda, map, filter
[OK] Gestion de fichiers (CSV, JSON)
[OK] Programmation orientée objet de base

Chapitre 2 : NumPy
[OK] Arrays vs Listes (performance)
[OK] Création d'arrays (arange, linspace, zeros, ones)
[OK] Shapes et dimensions
[OK] Indexing et slicing
[OK] Opérations vectorisées
[OK] Broadcasting
[OK] Statistiques
[OK] Données aléatoires

Chapitre 3 : Pandas
[OK] Series et DataFrame
[OK] Lecture/écriture de fichiers
[OK] Exploration initiale
[OK] Sélection ([], .loc, .iloc, filtrage)
[OK] Modification (colonnes, apply, where)
[OK] Tri et agrégation
[OK] GroupBy
[OK] Merge et Concat


[CLE] POINTS CLÉS À RETENIR

1. NumPy est 10-100x plus rapide que les listes Python
2. Toujours fixer le seed (np.random.seed(42))
3. Travailler sur des COPIES : df.copy()
4. Préférer .loc et .iloc pour la sélection
5. GroupBy = GROUP BY SQL
6. Merge = JOIN SQL
7. Vectorisation > Boucles for


-> PROCHAINE ÉTAPE : PARTIE 2

Vous allez apprendre :
- Exploration approfondie (EDA)
- Nettoyage de données (valeurs manquantes, doublons)
- Transformation et ingénierie des features

Prêt pour la Partie 2 ? [RAPIDE]
"""

# ============================================================================
# [LIVRE] ANALYSE DE DONNÉES AVEC PYTHON
# PARTIE 2 : EXPLORATION ET NETTOYAGE DE DONNÉES
# ============================================================================
#
# [OBJECTIF] CETTE PARTIE COUVRE :
# - Chapitre 4 : Exploration de Données (EDA)
# - Chapitre 5 : Nettoyage et Préparation des Données
# - Chapitre 6 : Transformation et Ingénierie des Features
#
# [TEMPS] TEMPS : ~8-10 heures
# [DOCS] PRÉREQUIS : Partie 1 complétée
# ============================================================================

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats
import warnings
warnings.filterwarnings("ignore")

# ============================================================================
# [GUIDE] CHAPITRE 4 : EXPLORATION DE DONNÉES (EDA)
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Effectuer une exploration systématique d'un dataset
[OK] Identifier les distributions des données
[OK] Détecter les outliers (valeurs aberrantes)
[OK] Analyser les relations entre variables
[OK] Créer un rapport EDA complet
[OK] Utiliser pandas-profiling pour EDA automatique
"""


# ----------------------------------------------------------------------------
# [REFLEXION] QU'EST-CE QUE L'EDA ?
# ----------------------------------------------------------------------------

"""
EDA = Exploratory Data Analysis (Analyse Exploratoire de Données)

[IDEE] DÉFINITION
C'est la première étape cruciale de tout projet d'analyse.
Vous "explorez" vos données pour les comprendre avant de les analyser.

POURQUOI C'EST CRUCIAL ?

Sans EDA -> Vous analysez "à l'aveugle"
            Risque de mauvaises conclusions
            Modèles ML incorrects

Avec EDA -> Comprendre la structure des données
           Identifier problèmes (valeurs manquantes, outliers)
           Choisir les bonnes analyses/modèles
           Générer des hypothèses

ANALOGIE :
Avant de cuisiner un plat, vous examinez les ingrédients :
- Sont-ils frais ? (qualité)
- Y en a-t-il assez ? (quantité)
- Certains sont-ils périmés ? (données corrompues)
-> C'est exactement ce que fait l'EDA !


LES 5 QUESTIONS DE L'EDA

1. [MESURE] Quelle est la taille du dataset ?
2. [TEXTE] Quels sont les types de données ?
3. [HOLE] Y a-t-il des valeurs manquantes ?
4. [GRAPHIQUE] Comment sont distribuées les données ?
5. [LIEN] Y a-t-il des relations entre variables ?
"""


# ----------------------------------------------------------------------------
# [GRAPHIQUE] CRÉER UN DATASET D'EXEMPLE RÉALISTE
# ----------------------------------------------------------------------------

np.random.seed(42)
n = 500  # 500 étudiants

# Simulation d'un dataset réaliste
villes = np.random.choice(
    ["Paris", "Lyon", "Marseille", "Bordeaux", "Lille"],
    n, p=[0.35, 0.25, 0.20, 0.10, 0.10]
)

filières = np.random.choice(
    ["Informatique", "Mathématiques", "Physique", "Chimie"],
    n, p=[0.40, 0.25, 0.20, 0.15]
)

# Notes influencées par la filière
notes_base = {"Informatique": 13, "Mathématiques": 14, "Physique": 12, "Chimie": 11}
notes_math = np.array([
    np.clip(np.random.normal(notes_base[f] + 1, 3), 0, 20) for f in filières
])
notes_info = np.array([
    np.clip(np.random.normal(notes_base[f], 2.5), 0, 20) for f in filières
])
notes_science = np.array([
    np.clip(np.random.normal(notes_base[f] - 0.5, 3.5), 0, 20) for f in filières
])

df = pd.DataFrame({
    "id": range(1, n + 1),
    "filière": filières,
    "ville": villes,
    "age": np.random.randint(18, 28, n),
    "note_math": notes_math.round(1),
    "note_info": notes_info.round(1),
    "note_science": notes_science.round(1),
    "heures_étude": np.clip(np.random.normal(20, 8, n), 0, 50).round(0),
    "bourse": np.random.choice([True, False], n, p=[0.3, 0.7])
})

# Ajouter valeurs manquantes réalistes (~5%)
for col in ["note_math", "note_info", "heures_étude"]:
    indices_nan = np.random.choice(n, int(n * 0.05), replace=False)
    df.loc[indices_nan, col] = np.nan

# Ajouter quelques outliers
df.loc[[10, 50, 100], "heures_étude"] = [80, 75, 90]  # Outliers hauts
df.loc[[20, 60], "note_math"] = [-5, 25]  # Erreurs de saisie

df["moyenne"] = df[["note_math", "note_info", "note_science"]].mean(axis=1).round(2)

print("Dataset créé !")
print(f"Shape : {df.shape}")


# ----------------------------------------------------------------------------
# [RECHERCHE] ÉTAPE 1 : PREMIÈRE VUE D'ENSEMBLE
# ----------------------------------------------------------------------------

"""
ANALYSE SYSTÉMATIQUE : LES 10 PREMIÈRES COMMANDES
"""

# 1. Vue d'ensemble
print("=" * 60)
print("1. PREMIÈRES ET DERNIÈRES LIGNES")
print("=" * 60)
print(df.head())
print("\n...")
print(df.tail(3))

# 2. Dimensions
print(f"\nDimensions : {df.shape[0]} lignes × {df.shape[1]} colonnes")

# 3. Types de données
print("\n" + "=" * 60)
print("2. TYPES DE DONNÉES")
print("=" * 60)
print(df.dtypes)

# 4. Informations complètes
print("\n" + "=" * 60)
print("3. INFORMATIONS COMPLÈTES")
print("=" * 60)
df.info()

# 5. Valeurs manquantes
print("\n" + "=" * 60)
print("4. VALEURS MANQUANTES")
print("=" * 60)
valeurs_manquantes = df.isnull().sum()
pct_manquants = (df.isnull().sum() / len(df) * 100).round(2)
rapport_nan = pd.DataFrame({
    "Nb manquants": valeurs_manquantes,
    "% manquants": pct_manquants
})
print(rapport_nan[rapport_nan["Nb manquants"] > 0])

# 6. Statistiques descriptives
print("\n" + "=" * 60)
print("5. STATISTIQUES DESCRIPTIVES")
print("=" * 60)
print(df.describe().round(2))

# 7. Valeurs uniques (variables catégorielles)
print("\n" + "=" * 60)
print("6. VALEURS UNIQUES")
print("=" * 60)
for col in df.select_dtypes(include=["object", "bool"]).columns:
    print(f"\n{col}:")
    print(df[col].value_counts())


# ----------------------------------------------------------------------------
# [HAUSSE] ÉTAPE 2 : ANALYSE DES DISTRIBUTIONS
# ----------------------------------------------------------------------------

"""
COMPRENDRE LA DISTRIBUTION D'UNE VARIABLE

POURQUOI :
- Comprendre "l'allure" des données
- Détecter les skewness (asymétrie)
- Identifier les outliers
- Choisir le bon test statistique

TYPES DE DISTRIBUTIONS COURANTES :

Normale (Gaussienne) :
  - Cloche symétrique
  - Ex: Taille humaine, erreurs de mesure
  - µ = médiane = mode

Uniforme :
  - Toutes valeurs équiprobables
  - Ex: Dés, génération aléatoire

Asymétrique (Skewed) :
  - Queue à gauche (négative) ou droite (positive)
  - Ex: Salaires (queue droite), âge décès

Bimodale :
  - Deux pics
  - Ex: Population mixte

Exponentielle :
  - Décroissance rapide
  - Ex: Temps d'attente
"""

fig, axes = plt.subplots(2, 3, figsize=(18, 10))
fig.suptitle("Analyse des Distributions", fontsize=16, y=1.02)

colonnes_num = ["note_math", "note_info", "note_science", "heures_étude", "age", "moyenne"]

for ax, col in zip(axes.flatten(), colonnes_num):
    # Données sans NaN
    data = df[col].dropna()

    # Histogramme + KDE
    ax.hist(data, bins=30, density=True, alpha=0.7, color="steelblue", edgecolor="white")

    # Ligne KDE (densité lissée)
    kde_x = np.linspace(data.min(), data.max(), 100)
    kde = stats.gaussian_kde(data)
    ax.plot(kde_x, kde(kde_x), "r-", lw=2, label="KDE")

    # Lignes statistiques
    ax.axvline(data.mean(), color="green", ls="--", label=f"Moyenne: {data.mean():.1f}")
    ax.axvline(data.median(), color="orange", ls="--", label=f"Médiane: {data.median():.1f}")

    ax.set_title(col, fontweight="bold")
    ax.legend(fontsize=8)
    ax.set_xlabel("Valeur")
    ax.set_ylabel("Densité")

plt.tight_layout()
plt.savefig("distributions.png", dpi=150, bbox_inches="tight")
plt.show()
print("Graphique distributions.png sauvegardé !")

"""
MESURES D'ASYMÉTRIE ET D'APLATISSEMENT
"""

print("\n" + "=" * 60)
print("ASYMÉTRIE (SKEWNESS) ET APLATISSEMENT (KURTOSIS)")
print("=" * 60)
print("""
Skewness = 0      : Distribution symétrique
Skewness > 0      : Queue à droite (valeurs élevées rares)
Skewness < 0      : Queue à gauche (valeurs faibles rares)

Kurtosis = 3      : Normale (mésokurtique)
Kurtosis > 3      : Pics pointus (leptokurtique)
Kurtosis < 3      : Pics aplatis (platykurtique)
""")

for col in ["note_math", "note_info", "note_science", "heures_étude"]:
    data = df[col].dropna()
    skew = data.skew()
    kurt = data.kurtosis()
    print(f"{col:20} | Skewness: {skew:+.3f} | Kurtosis: {kurt:+.3f}")


# ----------------------------------------------------------------------------
# [ALERTE] ÉTAPE 3 : DÉTECTION DES OUTLIERS
# ----------------------------------------------------------------------------

"""
QU'EST-CE QU'UN OUTLIER ?

Un outlier = Valeur qui s'éloigne significativement des autres

POURQUOI LES DÉTECTER ?
- Peuvent être des erreurs (saisie incorrecte)
- Peuvent être réels mais rares (cas exceptionnels)
- Affectent fortement la moyenne et les modèles ML
- Doivent être traités (suppression, correction, ou conservation)

3 MÉTHODES DE DÉTECTION

1. MÉTHODE IQR (Interquartile Range) - Non paramétrique
2. Z-SCORE - Paramétrique (suppose normalité)
3. VISUALISATION (Box plots)
"""

def detecter_outliers_iqr(series, facteur=1.5):
    """
    Détecter outliers avec méthode IQR

    COMMENT :
    Q1 = 25ème percentile
    Q3 = 75ème percentile
    IQR = Q3 - Q1

    Outliers :
    < Q1 - facteur * IQR  (outliers bas)
    > Q3 + facteur * IQR  (outliers hauts)

    POURQUOI facteur=1.5 ?
    -> Convention statistique standard
    -> facteur=3.0 pour outliers "extrêmes"
    """
    Q1 = series.quantile(0.25)
    Q3 = series.quantile(0.75)
    IQR = Q3 - Q1

    borne_basse = Q1 - facteur * IQR
    borne_haute = Q3 + facteur * IQR

    outliers = series[(series < borne_basse) | (series > borne_haute)]
    return outliers, borne_basse, borne_haute


def detecter_outliers_zscore(series, seuil=3):
    """
    Détecter outliers avec Z-score

    COMMENT :
    z = (x - µ) / σ

    Outliers : |z| > seuil

    POURQUOI seuil=3 ?
    -> Dans une distribution normale, 99.7% des données
      sont à moins de 3 écarts-types de la moyenne
    -> Les points au-delà sont "rares"
    """
    series_clean = series.dropna()
    z_scores = np.abs(stats.zscore(series_clean))
    outliers = series_clean[z_scores > seuil]
    return outliers, z_scores


# Analyse des outliers
print("=" * 60)
print("DÉTECTION DES OUTLIERS")
print("=" * 60)

for col in ["note_math", "note_info", "heures_étude"]:
    data = df[col].dropna()
    outliers_iqr, borne_b, borne_h = detecter_outliers_iqr(data)
    outliers_z, z_scores = detecter_outliers_zscore(data)

    print(f"\n[GRAPHIQUE] {col}")
    print(f"   Bornes IQR  : [{borne_b:.1f}, {borne_h:.1f}]")
    print(f"   Nb outliers IQR    : {len(outliers_iqr)}")
    print(f"   Nb outliers Z-score: {len(outliers_z)}")
    if len(outliers_iqr) > 0:
        print(f"   Valeurs outliers   : {sorted(outliers_iqr.values)}")


# Visualisation Box plots
fig, axes = plt.subplots(1, 4, figsize=(16, 6))
fig.suptitle("Détection des Outliers - Box Plots", fontsize=14)

colonnes_box = ["note_math", "note_info", "note_science", "heures_étude"]
couleurs = ["steelblue", "coral", "seagreen", "purple"]

for ax, col, couleur in zip(axes, colonnes_box, couleurs):
    data = df[col].dropna()
    box = ax.boxplot(data, patch_artist=True)
    box["boxes"][0].set_facecolor(couleur)
    box["boxes"][0].set_alpha(0.7)
    ax.set_title(col, fontweight="bold")
    ax.set_ylabel("Valeur")

    # Annotations statistiques
    Q1 = data.quantile(0.25)
    Q3 = data.quantile(0.75)
    ax.text(1.1, Q1, f"Q1={Q1:.1f}", va="center", fontsize=8)
    ax.text(1.1, Q3, f"Q3={Q3:.1f}", va="center", fontsize=8)

plt.tight_layout()
plt.savefig("boxplots_outliers.png", dpi=150, bbox_inches="tight")
plt.show()


# ----------------------------------------------------------------------------
# [LIEN] ÉTAPE 4 : ANALYSE DES RELATIONS ENTRE VARIABLES
# ----------------------------------------------------------------------------

"""
TYPES DE RELATIONS

1. CORRÉLATION NUMÉRIQUE-NUMÉRIQUE
   -> Scatter plot, Heatmap de corrélation

2. CATÉGORIELLE-NUMÉRIQUE
   -> Box plot par groupe, Violin plot

3. CATÉGORIELLE-CATÉGORIELLE
   -> Tableau croisé, Heatmap de fréquence
"""

"""
MATRICE DE CORRÉLATION
"""

print("\n" + "=" * 60)
print("MATRICE DE CORRÉLATION")
print("=" * 60)

colonnes_num = ["note_math", "note_info", "note_science", "heures_étude", "age"]
corr_matrix = df[colonnes_num].corr()
print(corr_matrix.round(3))

"""
INTERPRÉTATION CORRÉLATION :

r = +1.0  : Corrélation positive parfaite
r = +0.7  : Forte corrélation positive
r = +0.3  : Faible corrélation positive
r = 0.0   : Pas de corrélation linéaire
r = -0.3  : Faible corrélation négative
r = -0.7  : Forte corrélation négative
r = -1.0  : Corrélation négative parfaite

[ATTENTION] ATTENTION :
Corrélation ≠ Causalité !
"Les ventes de glaces et les noyades sont corrélées"
-> Pas parce que les glaces causent les noyades
-> Mais parce que les deux augmentent en été (variable cachée)
"""

# Heatmap de corrélation
fig, axes = plt.subplots(1, 2, figsize=(16, 6))

# Heatmap
sns.heatmap(
    corr_matrix,
    annot=True,          # Afficher valeurs
    fmt=".2f",           # Format 2 décimales
    cmap="coolwarm",     # Couleur (rouge=positif, bleu=négatif)
    center=0,            # Centrer sur 0
    vmin=-1, vmax=1,     # Bornes
    square=True,         # Carrés
    linewidths=0.5,      # Lignes entre cases
    ax=axes[0]
)
axes[0].set_title("Matrice de Corrélation", fontweight="bold")

# Scatter matrix (pairplot) - version manuelle
colonnes_plot = ["note_math", "note_info", "note_science", "moyenne"]
df_clean = df[colonnes_plot].dropna()
scatter_matrix = pd.plotting.scatter_matrix(
    df_clean,
    figsize=(10, 8),
    hist_kwds={"bins": 20},
    alpha=0.5
)

plt.tight_layout()
plt.savefig("correlations.png", dpi=150, bbox_inches="tight")
plt.show()


"""
ANALYSE CATÉGORIELLE
"""

# Moyennes par filière
print("\n" + "=" * 60)
print("NOTES MOYENNES PAR FILIÈRE")
print("=" * 60)
analyse_filière = df.groupby("filière")[["note_math", "note_info", "note_science", "moyenne"]].mean().round(2)
print(analyse_filière)

# Test statistique : Les moyennes sont-elles significativement différentes ?
print("\n" + "=" * 60)
print("TEST ANOVA (Différences entre filières)")
print("=" * 60)
groupes = [df[df["filière"] == f]["moyenne"].dropna() for f in df["filière"].unique()]
f_stat, p_value = stats.f_oneway(*groupes)
print(f"F-statistic : {f_stat:.3f}")
print(f"p-value     : {p_value:.6f}")
if p_value < 0.05:
    print("[OK] Différences significatives entre filières (p < 0.05)")
else:
    print("[X] Pas de différences significatives")

# Visualisation par filière
fig, axes = plt.subplots(1, 2, figsize=(16, 6))

# Box plot par filière
df_box = df[["filière", "moyenne"]].dropna()
filières_uniques = df_box["filière"].unique()
données_par_filière = [df_box[df_box["filière"] == f]["moyenne"].values for f in filières_uniques]

bp = axes[0].boxplot(données_par_filière, patch_artist=True, labels=filières_uniques)
couleurs_box = ["steelblue", "coral", "seagreen", "purple"]
for patch, couleur in zip(bp["boxes"], couleurs_box):
    patch.set_facecolor(couleur)
    patch.set_alpha(0.7)
axes[0].set_title("Distribution des moyennes par filière", fontweight="bold")
axes[0].set_ylabel("Moyenne générale")
axes[0].tick_params(axis="x", rotation=30)

# Barplot avec erreurs
moyennes = [données.mean() for données in données_par_filière]
erreurs = [données.std() for données in données_par_filière]
bars = axes[1].bar(filières_uniques, moyennes, yerr=erreurs, capsize=5,
                   color=couleurs_box, alpha=0.7, edgecolor="black")
axes[1].set_title("Moyenne par filière (± σ)", fontweight="bold")
axes[1].set_ylabel("Note moyenne")
axes[1].tick_params(axis="x", rotation=30)
axes[1].axhline(y=df["moyenne"].mean(), color="red", ls="--", label="Moyenne globale")
axes[1].legend()

plt.tight_layout()
plt.savefig("analyse_filière.png", dpi=150, bbox_inches="tight")
plt.show()


# ----------------------------------------------------------------------------
# [LISTE] ÉTAPE 5 : RAPPORT EDA AUTOMATISÉ
# ----------------------------------------------------------------------------

"""
FONCTION D'EDA AUTOMATIQUE
"""

def eda_complet(df, titre="Rapport EDA"):
    """
    Génère un rapport EDA complet pour un DataFrame

    COMMENT : Enchaîne toutes les analyses standard
    POURQUOI : Automatiser et standardiser l'exploration
    QUAND : Au début de chaque nouveau projet
    """
    print("=" * 70)
    print(f"[GRAPHIQUE] {titre.upper()}")
    print("=" * 70)

    # 1. Dimensions
    print(f"\n{'─'*40}")
    print("1. DIMENSIONS")
    print(f"{'─'*40}")
    print(f"   Lignes     : {df.shape[0]:,}")
    print(f"   Colonnes   : {df.shape[1]}")
    print(f"   Mémoire    : {df.memory_usage().sum() / 1024:.1f} KB")

    # 2. Types
    print(f"\n{'─'*40}")
    print("2. TYPES DE COLONNES")
    print(f"{'─'*40}")
    type_counts = df.dtypes.value_counts()
    for dtype, count in type_counts.items():
        print(f"   {str(dtype):15} : {count} colonnes")

    # 3. Valeurs manquantes
    print(f"\n{'─'*40}")
    print("3. VALEURS MANQUANTES")
    print(f"{'─'*40}")
    nan_info = pd.DataFrame({
        "Nb_NaN": df.isnull().sum(),
        "Pct_NaN": (df.isnull().sum() / len(df) * 100).round(2)
    }).sort_values("Nb_NaN", ascending=False)
    nan_with_data = nan_info[nan_info["Nb_NaN"] > 0]
    if len(nan_with_data) == 0:
        print("   [OK] Aucune valeur manquante !")
    else:
        print(nan_with_data.to_string())

    # 4. Doublons
    print(f"\n{'─'*40}")
    print("4. DOUBLONS")
    print(f"{'─'*40}")
    nb_doublons = df.duplicated().sum()
    print(f"   Doublons : {nb_doublons} ({nb_doublons/len(df)*100:.1f}%)")

    # 5. Colonnes numériques
    print(f"\n{'─'*40}")
    print("5. STATISTIQUES NUMÉRIQUES")
    print(f"{'─'*40}")
    print(df.describe().round(2).to_string())

    # 6. Colonnes catégorielles
    cat_cols = df.select_dtypes(include=["object", "bool", "category"]).columns
    if len(cat_cols) > 0:
        print(f"\n{'─'*40}")
        print("6. STATISTIQUES CATÉGORIELLES")
        print(f"{'─'*40}")
        for col in cat_cols:
            print(f"\n   {col} ({df[col].nunique()} valeurs uniques):")
            print("  ", df[col].value_counts().head(5).to_string())

    # 7. Corrélations
    num_cols = df.select_dtypes(include=[np.number]).columns
    if len(num_cols) >= 2:
        print(f"\n{'─'*40}")
        print("7. CORRÉLATIONS FORTES (|r| > 0.5)")
        print(f"{'─'*40}")
        corr = df[num_cols].corr()
        # Extraire corrélations fortes (hors diagonale)
        corr_pairs = []
        for i, col1 in enumerate(corr.columns):
            for j, col2 in enumerate(corr.columns):
                if i < j:  # Triangle supérieur seulement
                    r = corr.loc[col1, col2]
                    if abs(r) > 0.5:
                        corr_pairs.append((col1, col2, r))

        if corr_pairs:
            for col1, col2, r in sorted(corr_pairs, key=lambda x: abs(x[2]), reverse=True):
                signe = "[HAUSSE]" if r > 0 else "[BAISSE]"
                print(f"   {signe} {col1} <-> {col2}: r = {r:.3f}")
        else:
            print("   Aucune corrélation forte détectée")

    print("\n" + "=" * 70)
    print("[OK] EDA TERMINÉ")
    print("=" * 70)


# Utilisation
eda_complet(df, "Analyse Dataset Étudiants")


# ============================================================================
# [GUIDE] CHAPITRE 5 : NETTOYAGE ET PRÉPARATION DES DONNÉES
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Gérer les valeurs manquantes (NaN)
[OK] Gérer les doublons
[OK] Corriger les types de données
[OK] Détecter et traiter les outliers
[OK] Standardiser les données textuelles
[OK] Créer un pipeline de nettoyage reproductible
"""


# ----------------------------------------------------------------------------
# [HOLE] GESTION DES VALEURS MANQUANTES
# ----------------------------------------------------------------------------

"""
QU'EST-CE QU'UNE VALEUR MANQUANTE ?

NaN = Not a Number (np.nan en Python)
None, NA, N/A, ?, "" peuvent aussi représenter des manquants

TYPES DE DONNÉES MANQUANTES

MCAR (Missing Completely At Random) :
-> Données manquantes de façon aléatoire
-> Pas de pattern
-> Moins grave, imputation simple OK

MAR (Missing At Random) :
-> Données manquantes liées à une autre variable
-> Ex: Les hommes répondent moins aux questions de santé
-> Imputation conditionnelle

MNAR (Missing Not At Random) :
-> Les données manquent à cause de leur propre valeur
-> Ex: Revenus très élevés non déclarés
-> Le plus problématique, biais d'imputation

STRATÉGIES DE TRAITEMENT

1. SUPPRIMER (Dropna)
   -> Si peu de manquants ET MCAR

2. IMPUTER AVEC STATISTIQUE
   -> Moyenne (données normales)
   -> Médiane (données asymétriques ou avec outliers)
   -> Mode (données catégorielles)

3. IMPUTER AVEC PRÉDICTION
   -> KNN Imputer
   -> Regression Imputer
   -> IterativeImputer (MICE)

4. CRÉER INDICATEUR
   -> Ajouter colonne "est_manquant"
   -> Parfois l'absence est une information !
"""

df_clean = df.copy()  # Toujours copier avant modifier

"""
ANALYSER LES VALEURS MANQUANTES
"""

print("ANALYSE VALEURS MANQUANTES")
print("=" * 50)

# Détail par colonne
for col in df_clean.columns:
    nb_nan = df_clean[col].isnull().sum()
    if nb_nan > 0:
        pct = nb_nan / len(df_clean) * 100
        print(f"{col:20} : {nb_nan:4d} ({pct:.1f}%)")

# Pattern de valeurs manquantes
print("\nCombinations de NaN (premières lignes avec NaN):")
df_nan = df_clean[df_clean.isnull().any(axis=1)]
print(df_nan.head(5))

"""
MÉTHODE 1 : SUPPRIMER LES LIGNES AVEC NaN (dropna)
"""

# Supprimer toutes lignes avec NaN
df_sans_nan = df_clean.dropna()
print(f"\nAprès dropna() complet : {len(df_sans_nan)} lignes (était {len(df_clean)})")

# Supprimer si NaN dans colonnes spécifiques
df_sans_nan2 = df_clean.dropna(subset=["note_math", "note_info"])
print(f"Après dropna(subset) : {len(df_sans_nan2)} lignes")

# Supprimer si plus de N NaN
df_sans_nan3 = df_clean.dropna(thresh=len(df_clean.columns) - 2)

"""
MÉTHODE 2 : IMPUTATION SIMPLE
"""

df_impute = df_clean.copy()

# Imputer avec la médiane (robuste aux outliers)
mediane_math = df_impute["note_math"].median()
df_impute["note_math"].fillna(mediane_math, inplace=True)
print(f"\nMédiane note_math utilisée pour imputation : {mediane_math:.2f}")

# Imputer avec la moyenne
moy_info = df_impute["note_info"].mean()
df_impute["note_info"].fillna(moy_info, inplace=True)

# Imputer avec le mode (catégoriel)
# mode_filière = df_impute["filière"].mode()[0]
# df_impute["filière"].fillna(mode_filière, inplace=True)

# Imputer avec valeur constante
df_impute["heures_étude"].fillna(0, inplace=True)

# Imputer forward/backward fill (séries temporelles)
# df_impute["valeur"].fillna(method="ffill")  # Valeur précédente
# df_impute["valeur"].fillna(method="bfill")  # Valeur suivante

"""
MÉTHODE 3 : IMPUTATION AVANCÉE (KNN)
"""

from sklearn.impute import KNNImputer, SimpleImputer
from sklearn.experimental import enable_iterative_imputer
from sklearn.impute import IterativeImputer

# KNN Imputer : Utilise K voisins les plus proches
knn_imputer = KNNImputer(n_neighbors=5)
colonnes_num = ["note_math", "note_info", "note_science", "heures_étude"]

df_knn = df_clean.copy()
df_knn[colonnes_num] = knn_imputer.fit_transform(df_clean[colonnes_num])
print(f"\nAprès KNN Imputer :")
print(df_knn[colonnes_num].isnull().sum())

# Iterative Imputer (MICE - Multiple Imputation by Chained Equations)
mice_imputer = IterativeImputer(max_iter=10, random_state=42)
df_mice = df_clean.copy()
df_mice[colonnes_num] = mice_imputer.fit_transform(df_clean[colonnes_num])

"""
CRÉER COLONNE INDICATEUR

[IDEE] Quand l'absence elle-même est une information !
"""

df_indicateur = df_clean.copy()

# Avant imputation, créer indicateurs
for col in ["note_math", "note_info", "heures_étude"]:
    df_indicateur[f"{col}_était_nan"] = df_indicateur[col].isnull().astype(int)

print("\nColonnes indicateurs créées :")
print(df_indicateur.filter(like="_était_nan").sum())


# ----------------------------------------------------------------------------
# [SYNC] GESTION DES DOUBLONS
# ----------------------------------------------------------------------------

"""
TYPES DE DOUBLONS

1. DOUBLONS EXACTS : Toutes les valeurs identiques
2. QUASI-DOUBLONS : Même entité, légère variation
   Ex: "Alice Martin" et "alice martin"
"""

# Créer quelques doublons pour l'exemple
df_avec_doublons = pd.concat([df.head(50), df.head(5)], ignore_index=True)

# Détecter doublons
nb_doublons = df_avec_doublons.duplicated().sum()
print(f"Nombre de doublons exacts : {nb_doublons}")

# Voir les doublons
doublons = df_avec_doublons[df_avec_doublons.duplicated(keep=False)]
print(f"\nLignes dupliquées :\n{doublons.head()}")

# Supprimer doublons
df_sans_doublons = df_avec_doublons.drop_duplicates()
print(f"\nAprès suppression : {len(df_sans_doublons)} lignes")

# Supprimer doublons sur certaines colonnes
df_sans_doublons2 = df_avec_doublons.drop_duplicates(
    subset=["filière", "ville"],  # Identifier comme doublon si même filière+ville
    keep="first"                   # Garder le premier
)


# ----------------------------------------------------------------------------
# [NOMBRE] CORRECTION DES TYPES DE DONNÉES
# ----------------------------------------------------------------------------

"""
POURQUOI LES TYPES IMPORTENT ?

Colonne "2023-01-15" stockée en string :
-> Impossible de calculer durées
-> Tri alphabétique (incorrect)

Colonne "15.5" stockée en string :
-> Impossible de calculer moyennes
-> Plus lente à traiter
"""

# Dataset avec types incorrects
df_types = pd.DataFrame({
    "date": ["2023-01-15", "2023-02-20", "2023-03-10"],
    "note": ["15.5", "17", "12.3"],  # Notes en string
    "age": ["22", "23", "21"],       # Âge en string
    "actif": ["True", "False", "True"],  # Booléen en string
    "catégorie": ["A", "B", "A"],    # Catégorie
})

print("Types originaux :")
print(df_types.dtypes)

# Conversions
df_types["date"] = pd.to_datetime(df_types["date"])
df_types["note"] = pd.to_numeric(df_types["note"])
df_types["age"] = df_types["age"].astype(int)
df_types["actif"] = df_types["actif"].map({"True": True, "False": False})
df_types["catégorie"] = df_types["catégorie"].astype("category")  # Économise mémoire

print("\nTypes corrigés :")
print(df_types.dtypes)

# Conversion robuste (gère les erreurs)
col_avec_erreurs = pd.Series(["15", "17", "abc", "18", None])
col_num = pd.to_numeric(col_avec_erreurs, errors="coerce")  # 'abc' -> NaN
print(f"\nConversion robuste : {col_num.tolist()}")

"""
EXTRACTION D'INFORMATIONS DES DATES
"""

df_dates = pd.DataFrame({
    "date_inscription": pd.date_range("2020-01-15", periods=10, freq="45D"),
    "date_examen": pd.date_range("2020-06-01", periods=10, freq="3D")
})

# Extraire composantes
df_dates["année"] = df_dates["date_inscription"].dt.year
df_dates["mois"] = df_dates["date_inscription"].dt.month
df_dates["jour"] = df_dates["date_inscription"].dt.day
df_dates["jour_semaine"] = df_dates["date_inscription"].dt.dayofweek  # 0=Lundi
df_dates["nom_mois"] = df_dates["date_inscription"].dt.month_name()

# Calculer durée
df_dates["jours_jusqu_examen"] = (df_dates["date_examen"] - df_dates["date_inscription"]).dt.days

print("\nDataset avec dates :")
print(df_dates.head())


# ----------------------------------------------------------------------------
# [SHOWER] NETTOYAGE DES DONNÉES TEXTUELLES
# ----------------------------------------------------------------------------

"""
OPÉRATIONS DE NETTOYAGE DE TEXTE
"""

# Dataset avec texte "sale"
df_texte = pd.DataFrame({
    "nom": ["  Alice MARTIN  ", "bob durand", "CHARLIE Thomas", "  David  "],
    "email": ["alice@gmail.com", "BOB@YAHOO.FR", "  charlie@outlook.com  ", "david@"],
    "téléphone": ["06.12.34.56.78", "0612345678", "+33612345678", "06-12-34-56-78"]
})

print("AVANT nettoyage :")
print(df_texte)

# 1. Supprimer espaces en début/fin
df_texte["nom"] = df_texte["nom"].str.strip()

# 2. Uniformiser la casse
df_texte["nom"] = df_texte["nom"].str.title()   # Première lettre maj
df_texte["email"] = df_texte["email"].str.lower().str.strip()

# 3. Nettoyer téléphone
df_texte["téléphone"] = df_texte["téléphone"].str.replace(r"[.\-+\s]", "", regex=True)
df_texte["téléphone"] = df_texte["téléphone"].str.replace(r"^33", "0", regex=True)

# 4. Valider email
df_texte["email_valide"] = df_texte["email"].str.match(r"^[\w.+-]+@[\w-]+\.[a-z]{2,}$")

print("\nAPRÈS nettoyage :")
print(df_texte)

"""
OPÉRATIONS PANDAS SUR TEXTE (str accessor)

[IDEE] df["col"].str.XXX() applique XXX à chaque élément

str.strip()         -> Supprimer espaces
str.upper()         -> Majuscules
str.lower()         -> Minuscules
str.title()         -> Title Case
str.replace()       -> Remplacer
str.contains()      -> Contient (retourne booléen)
str.startswith()    -> Commence par
str.endswith()      -> Finit par
str.split()         -> Découper
str.extract()       -> Extraire avec regex
str.len()           -> Longueur
str.count()         -> Compter occurrences
str.find()          -> Trouver position
"""


# ----------------------------------------------------------------------------
# [OBJECTIF] TRAITEMENT DES OUTLIERS
# ----------------------------------------------------------------------------

"""
STRATÉGIES DE TRAITEMENT

1. SUPPRIMER -> Si clairement des erreurs de saisie
2. WINSORISER (Capping) -> Remplacer par borne max/min
3. TRANSFORMER -> Log, racine carrée
4. GARDER -> Si outliers représentent la réalité
"""

df_outliers = df.copy()

"""
STRATÉGIE 1 : SUPPRIMER LES OUTLIERS ÉVIDENTS (erreurs)
"""

# Notes < 0 ou > 20 : clairement des erreurs
condition_erronee = (df_outliers["note_math"] < 0) | (df_outliers["note_math"] > 20)
print(f"Notes invalides : {condition_erronee.sum()}")
df_outliers = df_outliers[~condition_erronee]

"""
STRATÉGIE 2 : WINSORISATION (Capping)

POURQUOI : Garder tous les points mais réduire l'influence des extrêmes
"""

def winsorer(series, lower_pct=0.05, upper_pct=0.95):
    """
    Winsoriser : remplacer les valeurs extrêmes par les percentiles
    """
    lower = series.quantile(lower_pct)
    upper = series.quantile(upper_pct)
    return series.clip(lower=lower, upper=upper)

df_outliers["heures_étude_wins"] = winsorer(df_outliers["heures_étude"].dropna(), 0.05, 0.95)

# Avec scipy
from scipy.stats import mstats
heures_clean = df_outliers["heures_étude"].dropna()
heures_wins = mstats.winsorize(heures_clean, limits=[0.05, 0.05])

"""
STRATÉGIE 3 : TRANSFORMATION LOGARITHMIQUE

POURQUOI : Réduit l'effet des grandes valeurs
           Rend les distributions asymétriques plus normales
"""

# Log transformation (ajouter 1 pour éviter log(0))
df_outliers["heures_log"] = np.log1p(df_outliers["heures_étude"])

# Racine carrée
df_outliers["heures_sqrt"] = np.sqrt(df_outliers["heures_étude"])


# ============================================================================
# [GUIDE] CHAPITRE 6 : TRANSFORMATION ET INGÉNIERIE DES FEATURES
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Normaliser et standardiser les données
[OK] Encoder les variables catégorielles
[OK] Créer de nouvelles variables (feature engineering)
[OK] Réduire les dimensions
[OK] Créer un pipeline de transformation
"""


# ----------------------------------------------------------------------------
# [MESURE] NORMALISATION ET STANDARDISATION
# ----------------------------------------------------------------------------

"""
POURQUOI NORMALISER/STANDARDISER ?

PROBLÈME :
- Note sur 20 -> [0, 20]
- Heures d'étude -> [0, 80]
- Âge -> [18, 30]

Sans normalisation :
-> Heures d'étude dominent les modèles ML
-> Comparaisons incorrectes

QUAND NORMALISER ?
- Algorithmes basés sur distance (KNN, SVM, K-means)
- Régression avec régularisation
- Réseaux de neurones

QUAND NE PAS NORMALISER ?
- Arbres de décision, Random Forest (insensibles)
- Quand les échelles ont un sens


TYPES DE NORMALISATION

1. MIN-MAX (Normalisation)
   x' = (x - min) / (max - min)
   -> Résultat : [0, 1]
   -> Sensible aux outliers

2. STANDARDISATION (Z-score)
   x' = (x - µ) / σ
   -> Résultat : µ=0, σ=1
   -> Plus robuste

3. ROBUST SCALER
   x' = (x - médiane) / IQR
   -> Très robuste aux outliers
"""

from sklearn.preprocessing import (
    MinMaxScaler, StandardScaler, RobustScaler, MaxAbsScaler
)

# Dataset d'exemple
data_norm = df[["note_math", "note_info", "heures_étude", "age"]].dropna()

print("AVANT normalisation :")
print(data_norm.describe().round(2))

# 1. Min-Max Scaler
min_max = MinMaxScaler()
data_minmax = pd.DataFrame(
    min_max.fit_transform(data_norm),
    columns=data_norm.columns
)
print("\nAprès MIN-MAX :")
print(data_minmax.describe().round(3))
# min ≈ 0, max ≈ 1 pour chaque colonne

# 2. Standard Scaler (Z-score)
standard = StandardScaler()
data_std = pd.DataFrame(
    standard.fit_transform(data_norm),
    columns=data_norm.columns
)
print("\nAprès STANDARDISATION :")
print(data_std.describe().round(3))
# mean ≈ 0, std ≈ 1 pour chaque colonne

# 3. Robust Scaler
robust = RobustScaler()
data_rob = pd.DataFrame(
    robust.fit_transform(data_norm),
    columns=data_norm.columns
)

"""
[ATTENTION] RÈGLE IMPORTANTE : fit_transform sur TRAIN, transform sur TEST

# [OK] CORRECT
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)  # Apprend et transforme
X_test_scaled = scaler.transform(X_test)         # Seulement transforme

# [X] INCORRECT (Data leakage !)
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.fit_transform(X_test)  # Apprend sur test !
"""


# ----------------------------------------------------------------------------
# [LABEL] ENCODAGE DES VARIABLES CATÉGORIELLES
# ----------------------------------------------------------------------------

"""
POURQUOI ENCODER ?

Les algorithmes ML ne comprennent que les nombres !
"Paris", "Lyon" -> doivent devenir des nombres


TYPES D'ENCODAGE

1. LABEL ENCODING
   "Paris" -> 0, "Lyon" -> 1, "Marseille" -> 2
   -> Pour variables ordinales (avec ordre)
   -> Problème : Implique un ordre artificiel

2. ONE-HOT ENCODING
   "Paris"    -> [1, 0, 0]
   "Lyon"     -> [0, 1, 0]
   "Marseille"-> [0, 0, 1]
   -> Pour variables nominales (sans ordre)
   -> Problème : Beaucoup de colonnes si haute cardinalité

3. ORDINAL ENCODING
   "Mauvais" -> 1, "Moyen" -> 2, "Bon" -> 3
   -> Pour variables ordinales avec ordre naturel

4. TARGET ENCODING (Encodage par cible)
   Remplacer catégorie par la moyenne de la variable cible
   -> Pour haute cardinalité
   -> Risque de data leakage
"""

from sklearn.preprocessing import LabelEncoder, OrdinalEncoder
from sklearn.preprocessing import OneHotEncoder

# 1. LABEL ENCODING (simple, mais implicite un ordre)
le = LabelEncoder()
df["filière_encoded"] = le.fit_transform(df["filière"])
print("Label Encoding :")
print(dict(zip(le.classes_, le.transform(le.classes_))))

# 2. ONE-HOT ENCODING avec pandas (recommandé)
df_ohe = pd.get_dummies(df, columns=["filière", "ville"], prefix=["fil", "vil"])
print(f"\nAprès One-Hot Encoding : {df_ohe.shape[1]} colonnes")
print(df_ohe.filter(like="fil_").head(3))

# 3. ONE-HOT avec sklearn
ohe = OneHotEncoder(sparse_output=False, handle_unknown="ignore")
categories = ohe.fit_transform(df[["filière"]])
cat_names = ohe.get_feature_names_out(["filière"])
df_ohe_sk = pd.DataFrame(categories, columns=cat_names)
print("\nOne-Hot Sklearn :")
print(df_ohe_sk.head(3))

# 4. ORDINAL ENCODING
mentions = pd.DataFrame({
    "mention": ["Passable", "Bien", "Très Bien", "Insuffisant", "Bien"]
})
oe = OrdinalEncoder(categories=[["Insuffisant", "Passable", "Bien", "Très Bien"]])
mentions["mention_ord"] = oe.fit_transform(mentions[["mention"]])
print("\nOrdinal Encoding :")
print(mentions)

# 5. TARGET ENCODING (manuel)
def target_encode(df, cat_col, target_col):
    """Encoder catégorie par moyenne de la cible"""
    mapping = df.groupby(cat_col)[target_col].mean()
    return df[cat_col].map(mapping)

df["ville_target_enc"] = target_encode(df, "ville", "moyenne")


# ----------------------------------------------------------------------------
# [OUTIL] INGÉNIERIE DES FEATURES (Feature Engineering)
# ----------------------------------------------------------------------------

"""
QU'EST-CE QUE LE FEATURE ENGINEERING ?

Créer de NOUVELLES variables à partir des existantes
pour améliorer la performance des modèles ML.

POURQUOI :
"Garbage in, garbage out"
-> Les bonnes features font la différence

TYPES DE NOUVELLES FEATURES

1. COMBINAISONS DE VARIABLES
2. TRANSFORMATIONS MATHÉMATIQUES
3. EXTRACTION D'INFORMATIONS TEMPORELLES
4. BINNING (Discrétisation)
5. INTERACTIONS ENTRE VARIABLES
"""

df_fe = df.copy()

"""
1. COMBINAISONS DE VARIABLES
"""

# Moyenne de toutes les notes
colonnes_notes = ["note_math", "note_info", "note_science"]
df_fe["note_moy"] = df_fe[colonnes_notes].mean(axis=1)

# Variabilité des notes
df_fe["note_std"] = df_fe[colonnes_notes].std(axis=1)

# Meilleure et pire note
df_fe["meilleure_note"] = df_fe[colonnes_notes].max(axis=1)
df_fe["pire_note"] = df_fe[colonnes_notes].min(axis=1)

# Différence entre meilleure et pire
df_fe["écart_notes"] = df_fe["meilleure_note"] - df_fe["pire_note"]

"""
2. RATIOS ET PROPORTIONS
"""

# Efficacité étude (note par heure)
df_fe["efficacité"] = df_fe["note_moy"] / (df_fe["heures_étude"] + 1)

# Relative performance (note vs moyenne globale)
moy_globale = df_fe["note_moy"].mean()
df_fe["perf_relative"] = df_fe["note_moy"] - moy_globale

"""
3. BINNING (Discrétisation)

Transformer variable continue en catégories
POURQUOI : Peut capturer des relations non-linéaires
"""

# Binning simple
df_fe["tranche_age"] = pd.cut(
    df_fe["age"],
    bins=[17, 20, 22, 24, 30],
    labels=["18-20", "21-22", "23-24", "25+"]
)

# Binning avec quantiles (tranches égales)
df_fe["note_quartile"] = pd.qcut(
    df_fe["note_moy"].dropna(),
    q=4,
    labels=["Q1 (faible)", "Q2", "Q3", "Q4 (fort)"],
    duplicates="drop"
)

# Binning personnalisé
def categoriser_note(note):
    if note >= 16:
        return "Excellent"
    elif note >= 14:
        return "Bien"
    elif note >= 10:
        return "Moyen"
    else:
        return "Insuffisant"

df_fe["catégorie_note"] = df_fe["note_moy"].apply(categoriser_note)

"""
4. VARIABLES INDICATRICES
"""

# Variables binaires
df_fe["est_boursier"] = df_fe["bourse"].astype(int)
df_fe["est_au_dessus_moy"] = (df_fe["note_moy"] > moy_globale).astype(int)
df_fe["étude_intensif"] = (df_fe["heures_étude"] > 30).astype(int)
df_fe["est_parisien"] = (df_fe["ville"] == "Paris").astype(int)

"""
5. INTERACTIONS ENTRE VARIABLES
"""

# Interaction étude × filière
df_fe["heures_x_informatique"] = (
    df_fe["heures_étude"] * (df_fe["filière"] == "Informatique").astype(int)
)

print("Features créées :")
nouvelles_features = [col for col in df_fe.columns if col not in df.columns]
print(nouvelles_features)


# ----------------------------------------------------------------------------
# [CONSTRUCTION] PIPELINE DE NETTOYAGE ET TRANSFORMATION
# ----------------------------------------------------------------------------

"""
POURQUOI UN PIPELINE ?

Sans pipeline :
-> Code répété pour train et test
-> Risque d'erreurs d'ordre
-> Difficile à reproduire

Avec pipeline :
-> Code propre et centralisé
-> Application cohérente
-> Reproducible
"""

from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder

# Définir colonnes par type
num_features = ["note_math", "note_info", "note_science", "heures_étude", "age"]
cat_features = ["filière", "ville"]

# Pipeline pour numériques
num_pipeline = Pipeline(steps=[
    ("imputer", SimpleImputer(strategy="median")),   # 1. Imputer
    ("scaler", StandardScaler())                      # 2. Standardiser
])

# Pipeline pour catégoriels
cat_pipeline = Pipeline(steps=[
    ("imputer", SimpleImputer(strategy="most_frequent")),  # 1. Imputer
    ("encoder", OneHotEncoder(handle_unknown="ignore"))     # 2. Encoder
])

# Combiner les deux pipelines
preprocessor = ColumnTransformer(transformers=[
    ("num", num_pipeline, num_features),
    ("cat", cat_pipeline, cat_features)
])

# Appliquer
df_pour_pipeline = df[num_features + cat_features].copy()
X_transformed = preprocessor.fit_transform(df_pour_pipeline)
print(f"\nShape après pipeline : {X_transformed.shape}")


# ============================================================================
# [COURS] EXERCICE PRATIQUE 2 : PIPELINE COMPLET DE NETTOYAGE
# ============================================================================

"""
OBJECTIF : Nettoyer un dataset "sale" et le préparer pour l'analyse

DONNÉES : Dataset e-commerce avec problèmes courants

CAHIER DES CHARGES :
1. Charger et explorer le dataset
2. Identifier et traiter les valeurs manquantes
3. Corriger les types de données
4. Détecter et traiter les outliers
5. Nettoyer le texte
6. Créer de nouvelles features
7. Préparer pour le ML (encodage + normalisation)

SOLUTION COMPLÈTE :
"""

import numpy as np
import pandas as pd
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
import warnings
warnings.filterwarnings("ignore")

np.random.seed(42)

# ==========================================
# CRÉER UN DATASET "SALE" RÉALISTE
# ==========================================
n = 300

# Simuler commandes e-commerce avec erreurs courantes
catégories = np.random.choice(["Électronique", "Vêtements", "Alimentation", "Livres"], n)
prix_base = {"Électronique": 200, "Vêtements": 60, "Alimentation": 25, "Livres": 20}

data_brut = {
    "commande_id": [f"CMD{i:04d}" for i in range(1, n + 1)],
    "date": pd.date_range("2023-01-01", periods=n, freq="D").strftime("%Y/%m/%d").tolist(),
    "client_age": np.random.choice(
        list(range(18, 80)) + [999, -5, 0],  # Erreurs : 999, -5, 0
        n
    ),
    "catégorie": np.random.choice(
        ["Électronique", "Vêtements", "Alimentation", "Livres",
         "électronique", "VÊTEMENTS", ""],  # Casse incohérente, vide
        n
    ),
    "montant": np.array([
        np.random.normal(prix_base.get(cat, 100), 30)
        if cat in prix_base else None
        for cat in np.random.choice(list(prix_base.keys()), n)
    ]),
    "quantité": np.random.choice([1, 2, 3, 4, 5, None, -1, 100], n),
    "satisfaction": np.random.choice([1, 2, 3, 4, 5, None], n, p=[0.1, 0.15, 0.25, 0.3, 0.15, 0.05]),
    "region": np.random.choice(["Nord", "Sud", "Est", "Ouest", None], n, p=[0.25, 0.25, 0.25, 0.2, 0.05]),
    "promo": np.random.choice(["True", "False", "1", "0", "Oui", "Non"], n),
}

df_brut = pd.DataFrame(data_brut)

# Ajouter doublons
df_brut = pd.concat([df_brut, df_brut.head(10)], ignore_index=True)
n = len(df_brut)

print("=" * 60)
print("DATASET BRUT - ÉTAT INITIAL")
print("=" * 60)
print(df_brut.head(10))
print(f"\nShape : {df_brut.shape}")
print(f"\nTypes :\n{df_brut.dtypes}")
print(f"\nValeurs manquantes :\n{df_brut.isnull().sum()}")

# ==========================================
# ÉTAPE 1 : SUPPRIMER LES DOUBLONS
# ==========================================
print("\n" + "=" * 60)
print("ÉTAPE 1 : DOUBLONS")
print("=" * 60)

nb_avant = len(df_brut)
df_propre = df_brut.drop_duplicates()
nb_après = len(df_propre)
print(f"Doublons supprimés : {nb_avant - nb_après}")
print(f"Lignes restantes : {nb_après}")

# ==========================================
# ÉTAPE 2 : CORRIGER LES TYPES DE DONNÉES
# ==========================================
print("\n" + "=" * 60)
print("ÉTAPE 2 : TYPES DE DONNÉES")
print("=" * 60)

# Dates
df_propre["date"] = pd.to_datetime(df_propre["date"], format="%Y/%m/%d")
print("[OK] Date convertie en datetime")

# Montant et quantité
df_propre["montant"] = pd.to_numeric(df_propre["montant"], errors="coerce")
df_propre["quantité"] = pd.to_numeric(df_propre["quantité"], errors="coerce")
print("[OK] Montant et quantité en numérique")

# Booléen promo
df_propre["promo"] = df_propre["promo"].replace({
    "True": True, "1": True, "Oui": True,
    "False": False, "0": False, "Non": False
})
print("[OK] Promo converti en booléen")

print("\nTypes après correction :")
print(df_propre.dtypes)

# ==========================================
# ÉTAPE 3 : NETTOYER LE TEXTE
# ==========================================
print("\n" + "=" * 60)
print("ÉTAPE 3 : NETTOYAGE TEXTE")
print("=" * 60)

# Standardiser catégorie
df_propre["catégorie"] = df_propre["catégorie"].str.strip().str.title()

# Remplacer catégories vides
df_propre["catégorie"] = df_propre["catégorie"].replace("", np.nan)

# Corriger orthographe
correction_catégorie = {
    "Electronique": "Électronique",
    "Vetements": "Vêtements",
    "Vêtements": "Vêtements"
}
df_propre["catégorie"] = df_propre["catégorie"].replace(correction_catégorie)

print("Catégories après nettoyage :")
print(df_propre["catégorie"].value_counts())

# ==========================================
# ÉTAPE 4 : TRAITER LES VALEURS ABERRANTES
# ==========================================
print("\n" + "=" * 60)
print("ÉTAPE 4 : OUTLIERS")
print("=" * 60)

# Age : remplacer valeurs impossibles par NaN
df_propre["client_age"] = df_propre["client_age"].where(
    df_propre["client_age"].between(18, 100), other=np.nan
)
print(f"Ages invalides remplacés par NaN")

# Quantité : valeurs négatives ou > 50 -> NaN
df_propre["quantité"] = df_propre["quantité"].where(
    df_propre["quantité"].between(1, 50), other=np.nan
)

# Montant : Winsorisation (pas de suppression, juste écrêter)
def winsorer(series, q_low=0.05, q_high=0.95):
    low = series.quantile(q_low)
    high = series.quantile(q_high)
    return series.clip(low, high)

df_propre["montant"] = winsorer(df_propre["montant"].fillna(df_propre["montant"].median()))

print("Statistiques après traitement outliers :")
print(df_propre[["client_age", "quantité", "montant"]].describe().round(2))

# ==========================================
# ÉTAPE 5 : GÉRER LES VALEURS MANQUANTES
# ==========================================
print("\n" + "=" * 60)
print("ÉTAPE 5 : VALEURS MANQUANTES")
print("=" * 60)

print("Manquants avant :")
print(df_propre.isnull().sum())

# Stratégies différentes selon colonne
df_propre["client_age"].fillna(df_propre["client_age"].median(), inplace=True)
df_propre["quantité"].fillna(1, inplace=True)  # Supposer 1 si manquant
df_propre["satisfaction"].fillna(df_propre["satisfaction"].median(), inplace=True)
df_propre["region"].fillna("Inconnu", inplace=True)
df_propre["catégorie"].fillna(df_propre["catégorie"].mode()[0], inplace=True)

print("\nManquants après :")
print(df_propre.isnull().sum())

# ==========================================
# ÉTAPE 6 : INGÉNIERIE DES FEATURES
# ==========================================
print("\n" + "=" * 60)
print("ÉTAPE 6 : FEATURE ENGINEERING")
print("=" * 60)

# Extraction temporelle
df_propre["mois"] = df_propre["date"].dt.month
df_propre["jour_semaine"] = df_propre["date"].dt.dayofweek
df_propre["est_weekend"] = (df_propre["jour_semaine"] >= 5).astype(int)
df_propre["trimestre"] = df_propre["date"].dt.quarter

# Montant total
df_propre["montant_total"] = df_propre["montant"] * df_propre["quantité"]

# Tranche d'âge
df_propre["tranche_age"] = pd.cut(
    df_propre["client_age"],
    bins=[17, 25, 35, 50, 100],
    labels=["18-25", "26-35", "36-50", "51+"]
)

# Client premium (satisfaction élevée + montant élevé)
df_propre["client_premium"] = (
    (df_propre["satisfaction"] >= 4) &
    (df_propre["montant_total"] > df_propre["montant_total"].quantile(0.75))
).astype(int)

nouvelles = ["mois", "jour_semaine", "est_weekend", "trimestre",
             "montant_total", "tranche_age", "client_premium"]
print(f"Nouvelles features créées : {nouvelles}")

# ==========================================
# ÉTAPE 7 : PRÉPARER POUR ML
# ==========================================
print("\n" + "=" * 60)
print("ÉTAPE 7 : PRÉPARATION ML")
print("=" * 60)

# Sélectionner features finales
features_num = ["client_age", "montant", "quantité", "satisfaction",
                "mois", "jour_semaine", "montant_total"]
features_cat = ["catégorie", "region", "tranche_age"]
target = "client_premium"

X = df_propre[features_num + features_cat]
y = df_propre[target]

# Pipeline de transformation
num_pipe = Pipeline([
    ("imputer", SimpleImputer(strategy="median")),
    ("scaler", StandardScaler())
])

cat_pipe = Pipeline([
    ("imputer", SimpleImputer(strategy="most_frequent")),
    ("encoder", OneHotEncoder(handle_unknown="ignore", sparse_output=False))
])

preprocessor = ColumnTransformer([
    ("num", num_pipe, features_num),
    ("cat", cat_pipe, features_cat)
])

X_final = preprocessor.fit_transform(X)
print(f"Shape X final : {X_final.shape}")
print(f"Distribution cible :\n{y.value_counts()}")

# ==========================================
# RAPPORT FINAL
# ==========================================
print("\n" + "=" * 60)
print("[GRAPHIQUE] RAPPORT DE NETTOYAGE")
print("=" * 60)
print(f"Lignes avant  : {n}")
print(f"Lignes après  : {len(df_propre)}")
print(f"Doublons supp.: {n - len(df_propre)}")
print(f"Features créées: {len(nouvelles)}")
print(f"Features finales: {X_final.shape[1]}")
print(f"\nDataset prêt pour l'analyse [OK]")

# Sauvegarder
df_propre.to_csv("ecommerce_propre.csv", index=False, encoding="utf-8")
print("Fichier ecommerce_propre.csv sauvegardé !")

# ============================================================================
# [DOCS] RÉCAPITULATIF PARTIE 2
# ============================================================================

"""
[BRAVO] FÉLICITATIONS ! PARTIE 2 TERMINÉE !

VOUS MAÎTRISEZ MAINTENANT :

Chapitre 4 : EDA
[OK] Vue d'ensemble systématique (info, describe, nunique)
[OK] Analyse des distributions (histogramme, KDE, skewness)
[OK] Détection d'outliers (IQR, Z-score, boxplots)
[OK] Analyse des relations (corrélation, groupes)
[OK] Rapport EDA automatisé

Chapitre 5 : Nettoyage
[OK] Valeurs manquantes (dropna, fillna, KNN imputer)
[OK] Doublons (drop_duplicates)
[OK] Types de données (to_datetime, to_numeric, astype)
[OK] Texte (str.strip, str.lower, regex)
[OK] Outliers (suppression, winsorisation, transformation)

Chapitre 6 : Feature Engineering
[OK] Normalisation (MinMax, Standard, Robust)
[OK] Encodage (LabelEncoder, OneHot, Ordinal)
[OK] Nouvelles variables (combinaisons, ratios, binning)
[OK] Pipeline sklearn complet


[CLE] RÈGLES D'OR DU NETTOYAGE

1. Toujours travailler sur une COPIE (df.copy())
2. Explorer AVANT de nettoyer (comprendre le problème)
3. Documenter CHAQUE décision de nettoyage
4. fit_transform sur train, transform sur test SEULEMENT
5. Créer pipeline pour reproductibilité
6. Vérifier les statistiques AVANT et APRÈS
7. Conserver les données brutes originales


-> PROCHAINE ÉTAPE : PARTIE 3 - VISUALISATION

Vous allez apprendre :
- Matplotlib (contrôle total)
- Seaborn (statistique et élégance)
- Plotly (interactivité)
- Pandas plots intégrés

Prêt pour des graphiques magnifiques ? [DESIGN]
"""

# ============================================================================
# [LIVRE] ANALYSE DE DONNÉES AVEC PYTHON
# PARTIE 3 : VISUALISATION DE DONNÉES
# ============================================================================
#
# [OBJECTIF] CETTE PARTIE COUVRE :
# - Chapitre 7 : Matplotlib - Visualisation de Base
# - Chapitre 8 : Seaborn - Visualisation Statistique
# - Chapitre 9 : Plotly - Visualisation Interactive
# - Chapitre 10 : Pandas Visualisation Intégrée
#
# [TEMPS] TEMPS : ~8-10 heures
# [DOCS] PRÉREQUIS : Parties 1 et 2 complétées
# ============================================================================

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.gridspec import GridSpec
import seaborn as sns
import warnings
warnings.filterwarnings("ignore")

# Paramètres globaux de style
plt.rcParams["figure.dpi"] = 100
plt.rcParams["font.family"] = "DejaVu Sans"
plt.rcParams["axes.spines.top"] = False
plt.rcParams["axes.spines.right"] = False

# Dataset principal
np.random.seed(42)
n = 400

df = pd.DataFrame({
    "filière": np.random.choice(["Informatique", "Maths", "Physique", "Chimie"], n,
                                 p=[0.4, 0.25, 0.2, 0.15]),
    "ville": np.random.choice(["Paris", "Lyon", "Marseille", "Bordeaux"], n,
                               p=[0.35, 0.3, 0.2, 0.15]),
    "age": np.random.randint(18, 28, n),
    "note_math": np.clip(np.random.normal(13, 3, n), 0, 20).round(1),
    "note_info": np.clip(np.random.normal(14, 2.5, n), 0, 20).round(1),
    "note_science": np.clip(np.random.normal(12, 3.5, n), 0, 20).round(1),
    "heures_étude": np.clip(np.random.normal(20, 8, n), 0, 50).round(0),
    "bourse": np.random.choice([True, False], n, p=[0.3, 0.7])
})
df["moyenne"] = df[["note_math", "note_info", "note_science"]].mean(axis=1).round(2)
df["mention"] = pd.cut(df["moyenne"],
                        bins=[0, 10, 12, 14, 16, 20],
                        labels=["Insuf.", "Passable", "Assez B.", "Bien", "Très B."])


# ============================================================================
# [GUIDE] CHAPITRE 7 : MATPLOTLIB - VISUALISATION DE BASE
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Comprendre l'architecture de Matplotlib
[OK] Créer tous les types de graphiques de base
[OK] Personnaliser couleurs, styles, annotations
[OK] Créer des figures multi-panneaux
[OK] Sauvegarder en haute qualité
"""


# ----------------------------------------------------------------------------
# [CONSTRUCTION] ARCHITECTURE MATPLOTLIB
# ----------------------------------------------------------------------------

"""
DEUX INTERFACES

1. PYPLOT (Interface procédurale) - Simple
   plt.plot(), plt.title(), etc.
   -> Pour graphiques rapides

2. ORIENTÉE OBJET - Recommandée
   fig, ax = plt.subplots()
   ax.plot(), ax.set_title(), etc.
   -> Pour graphiques complexes et personnalisés


ANATOMIE D'UNE FIGURE MATPLOTLIB

┌────────────────────────────────────────┐
│              FIGURE                    │
│  ┌──────────────────────────────────┐  │
│  │           AXES                   │  │
│  │                                  │  │
│  │  TITRE                           │  │
│  │  ┌────────────────────────────┐  │  │
│  │  │         PLOT AREA          │  │  │
│  │  │                            │  │  │
│  │  │   •  ••                    │  │  │
│  │  │        ••   •              │  │  │
│  │  │            ••              │  │  │
│  │  └────────────────────────────┘  │  │
│  │  XLABEL                          │  │
│  └──────────────────────────────────┘  │
└────────────────────────────────────────┘

FIGURE  -> Conteneur global (toute l'image)
AXES    -> Zone de tracé (un "graphique")
AXIS    -> Les axes X et Y
"""


# ----------------------------------------------------------------------------
# [HAUSSE] GRAPHIQUE LINÉAIRE (Line Plot)
# ----------------------------------------------------------------------------

"""
QUAND UTILISER :
-> Évolution dans le temps
-> Tendances continues
-> Comparaison de séries
"""

# Données
x = np.linspace(0, 2 * np.pi, 100)

fig, axes = plt.subplots(1, 2, figsize=(14, 5))
fig.suptitle("Graphiques Linéaires", fontsize=14, fontweight="bold")

# Simple
axes[0].plot(x, np.sin(x), label="sin(x)", color="steelblue", linewidth=2)
axes[0].plot(x, np.cos(x), label="cos(x)", color="coral", linewidth=2, linestyle="--")
axes[0].axhline(y=0, color="black", linewidth=0.5)
axes[0].set_title("Fonctions trigonométriques")
axes[0].set_xlabel("x")
axes[0].set_ylabel("y")
axes[0].legend()
axes[0].grid(True, alpha=0.3)

# Avec remplissage (fill)
axes[1].fill_between(x, np.sin(x), alpha=0.3, color="steelblue", label="sin(x)")
axes[1].fill_between(x, np.cos(x), alpha=0.3, color="coral", label="cos(x)")
axes[1].plot(x, np.sin(x), color="steelblue", linewidth=1.5)
axes[1].plot(x, np.cos(x), color="coral", linewidth=1.5)
axes[1].set_title("Avec remplissage")
axes[1].legend()
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig("ch7_lineaire.png", dpi=150, bbox_inches="tight")
plt.show()

"""
OPTIONS IMPORTANTES POUR PLOT :

color      = "red", "#FF0000", (1,0,0), "C0"
linewidth  = 1, 2, 3 (épaisseur)
linestyle  = "-", "--", ":", "-."
marker     = "o", "s", "^", "*", "+"
markersize = 5, 10
alpha      = 0.3, 0.7 (transparence)
label      = "Ma série" (pour légende)
"""


# ----------------------------------------------------------------------------
# [GRAPHIQUE] HISTOGRAMME
# ----------------------------------------------------------------------------

"""
QUAND UTILISER :
-> Distribution d'une variable continue
-> Identifier la forme (normale, asymétrique, bimodale)
"""

fig, axes = plt.subplots(1, 3, figsize=(16, 5))
fig.suptitle("Histogrammes", fontsize=14, fontweight="bold")

# Simple
axes[0].hist(df["moyenne"], bins=30, color="steelblue", edgecolor="white", alpha=0.8)
axes[0].set_title("Histogramme simple")
axes[0].set_xlabel("Moyenne")
axes[0].set_ylabel("Fréquence")
axes[0].axvline(df["moyenne"].mean(), color="red", ls="--", label=f"Moy: {df['moyenne'].mean():.1f}")
axes[0].axvline(df["moyenne"].median(), color="orange", ls="--", label=f"Méd: {df['moyenne'].median():.1f}")
axes[0].legend()

# Normalisé (densité)
axes[1].hist(df["moyenne"], bins=30, density=True, color="coral", edgecolor="white", alpha=0.8)
# KDE par-dessus
from scipy.stats import gaussian_kde
kde_x = np.linspace(df["moyenne"].min(), df["moyenne"].max(), 200)
kde = gaussian_kde(df["moyenne"].dropna())
axes[1].plot(kde_x, kde(kde_x), "b-", lw=2.5, label="KDE")
axes[1].set_title("Histogramme + KDE")
axes[1].legend()

# Multiple groupes
filières = df["filière"].unique()
couleurs = ["steelblue", "coral", "seagreen", "purple"]
for filière, couleur in zip(filières, couleurs):
    data_f = df[df["filière"] == filière]["moyenne"]
    axes[2].hist(data_f, bins=20, alpha=0.5, label=filière, color=couleur)
axes[2].set_title("Histogramme par filière")
axes[2].legend()
axes[2].set_xlabel("Moyenne")

plt.tight_layout()
plt.savefig("ch7_histogrammes.png", dpi=150, bbox_inches="tight")
plt.show()


# ----------------------------------------------------------------------------
# [PACKAGE] BOX PLOT (Boîte à moustaches)
# ----------------------------------------------------------------------------

"""
QUAND UTILISER :
-> Comparer distributions entre groupes
-> Visualiser médiane, quartiles et outliers
-> Détecter asymétrie

LECTURE D'UN BOX PLOT :

     ─── Moustache haute (Q3 + 1.5*IQR)
     │
  ┌──┴──┐
  │     │  -> Q3 (75ème percentile)
  │─────│  -> Médiane
  │     │  -> Q1 (25ème percentile)
  └──┬──┘
     │
     ─── Moustache basse (Q1 - 1.5*IQR)

  o   <- Outliers (points au-delà)
"""

fig, axes = plt.subplots(1, 2, figsize=(14, 6))
fig.suptitle("Box Plots", fontsize=14, fontweight="bold")

# Par filière
données_box = [df[df["filière"] == f]["moyenne"].dropna().values
               for f in df["filière"].unique()]
étiquettes = list(df["filière"].unique())

bp = axes[0].boxplot(
    données_box,
    labels=étiquettes,
    patch_artist=True,   # Boîtes colorées
    notch=False,         # Encoche (intervalle confiance médiane)
    showfliers=True      # Afficher outliers
)

# Colorier
couleurs = ["steelblue", "coral", "seagreen", "purple"]
for patch, couleur in zip(bp["boxes"], couleurs):
    patch.set_facecolor(couleur)
    patch.set_alpha(0.7)
for median in bp["medians"]:
    median.set_color("black")
    median.set_linewidth(2)

axes[0].set_title("Moyenne par filière")
axes[0].set_ylabel("Moyenne générale")
axes[0].tick_params(axis="x", rotation=30)
axes[0].grid(True, alpha=0.3, axis="y")

# Comparaison boursiers vs non-boursiers
for bourse, label, couleur in zip([True, False], ["Boursier", "Non-boursier"], ["gold", "silver"]):
    data = df[df["bourse"] == bourse]["moyenne"].dropna().values
    position = [0.5 if bourse else 1.5]
    bp2 = axes[1].boxplot(data, positions=position, widths=0.3, patch_artist=True)
    bp2["boxes"][0].set_facecolor(couleur)
    bp2["boxes"][0].set_alpha(0.8)

axes[1].set_xlim(0, 2)
axes[1].set_xticks([0.5, 1.5])
axes[1].set_xticklabels(["Boursier", "Non-boursier"])
axes[1].set_title("Moyenne selon statut bourse")
axes[1].set_ylabel("Moyenne générale")
axes[1].grid(True, alpha=0.3, axis="y")

plt.tight_layout()
plt.savefig("ch7_boxplots.png", dpi=150, bbox_inches="tight")
plt.show()


# ----------------------------------------------------------------------------
# [NOIR] SCATTER PLOT (Nuage de points)
# ----------------------------------------------------------------------------

"""
QUAND UTILISER :
-> Relation entre deux variables numériques
-> Identifier corrélations
-> Détecter clusters ou outliers
"""

fig, axes = plt.subplots(1, 2, figsize=(14, 6))
fig.suptitle("Scatter Plots", fontsize=14, fontweight="bold")

# Simple
sc = axes[0].scatter(
    df["heures_étude"],
    df["moyenne"],
    c=df["age"],           # Couleur selon l'âge
    cmap="viridis",        # Palette de couleurs
    alpha=0.6,
    s=50,                  # Taille des points
    edgecolors="none"
)
plt.colorbar(sc, ax=axes[0], label="Âge")

# Ligne de tendance
z = np.polyfit(df["heures_étude"].dropna(), df["moyenne"].dropna(), 1)
p = np.poly1d(z)
x_trend = np.linspace(df["heures_étude"].min(), df["heures_étude"].max(), 100)
axes[0].plot(x_trend, p(x_trend), "r--", lw=2, label="Tendance")

# Corrélation
from scipy.stats import pearsonr
r, pval = pearsonr(df["heures_étude"].dropna(), df["moyenne"].dropna())
axes[0].set_title(f"Heures étude vs Moyenne (r = {r:.3f}, p = {pval:.3f})")
axes[0].set_xlabel("Heures d'étude")
axes[0].set_ylabel("Moyenne générale")
axes[0].legend()
axes[0].grid(True, alpha=0.3)

# Scatter par groupe
couleurs_filière = {"Informatique": "steelblue", "Maths": "coral",
                    "Physique": "seagreen", "Chimie": "purple"}
for filière, couleur in couleurs_filière.items():
    mask = df["filière"] == filière
    axes[1].scatter(df[mask]["note_math"], df[mask]["note_info"],
                    c=couleur, label=filière, alpha=0.6, s=40)

axes[1].set_xlabel("Note Math")
axes[1].set_ylabel("Note Info")
axes[1].set_title("Math vs Info par filière")
axes[1].legend()
axes[1].plot([0, 20], [0, 20], "k--", alpha=0.3, label="y=x")
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig("ch7_scatter.png", dpi=150, bbox_inches="tight")
plt.show()


# ----------------------------------------------------------------------------
# [GRAPHIQUE] BAR PLOT (Barres)
# ----------------------------------------------------------------------------

"""
QUAND UTILISER :
-> Comparer des valeurs entre catégories
-> Compter des occurrences
"""

fig, axes = plt.subplots(2, 2, figsize=(14, 10))
fig.suptitle("Bar Plots", fontsize=14, fontweight="bold")

# 1. Barres simples
comptage = df["filière"].value_counts()
axes[0, 0].bar(comptage.index, comptage.values,
               color=couleurs, alpha=0.8, edgecolor="black", linewidth=0.5)
axes[0, 0].set_title("Nombre d'étudiants par filière")
axes[0, 0].set_ylabel("Nombre")
axes[0, 0].tick_params(axis="x", rotation=30)
# Annotations sur les barres
for i, (val, _) in enumerate(zip(comptage.values, comptage.index)):
    axes[0, 0].text(i, val + 1, str(val), ha="center", fontsize=9)

# 2. Barres horizontales
moy_par_ville = df.groupby("ville")["moyenne"].mean().sort_values()
axes[0, 1].barh(moy_par_ville.index, moy_par_ville.values,
                color="steelblue", alpha=0.8)
axes[0, 1].set_title("Moyenne générale par ville")
axes[0, 1].set_xlabel("Moyenne")
axes[0, 1].axvline(x=df["moyenne"].mean(), color="red", ls="--", label="Moy. globale")
axes[0, 1].legend()
for i, val in enumerate(moy_par_ville.values):
    axes[0, 1].text(val + 0.1, i, f"{val:.2f}", va="center")

# 3. Barres groupées
moy_multi = df.groupby("filière")[["note_math", "note_info", "note_science"]].mean()
x = np.arange(len(moy_multi.index))
width = 0.25
axes[1, 0].bar(x - width, moy_multi["note_math"], width, label="Math", alpha=0.8, color="steelblue")
axes[1, 0].bar(x, moy_multi["note_info"], width, label="Info", alpha=0.8, color="coral")
axes[1, 0].bar(x + width, moy_multi["note_science"], width, label="Science", alpha=0.8, color="seagreen")
axes[1, 0].set_title("Notes par filière et matière")
axes[1, 0].set_xticks(x)
axes[1, 0].set_xticklabels(moy_multi.index, rotation=30)
axes[1, 0].legend()
axes[1, 0].set_ylabel("Note moyenne")

# 4. Barres empilées (stacked)
mention_ville = pd.crosstab(df["ville"], df["mention"], normalize="index") * 100
mentions = mention_ville.columns
couleurs_mentions = ["#d73027", "#fc8d59", "#fee090", "#91bfdb", "#4575b4"]
bottom = np.zeros(len(mention_ville))
for mention, couleur in zip(mentions, couleurs_mentions):
    if mention in mention_ville.columns:
        vals = mention_ville[mention].values
        axes[1, 1].bar(mention_ville.index, vals, bottom=bottom,
                       label=str(mention), color=couleur, alpha=0.9)
        bottom += vals
axes[1, 1].set_title("Répartition des mentions par ville (%)")
axes[1, 1].set_ylabel("Pourcentage (%)")
axes[1, 1].legend(title="Mention", bbox_to_anchor=(1.05, 1))
axes[1, 1].tick_params(axis="x", rotation=30)

plt.tight_layout()
plt.savefig("ch7_barplots.png", dpi=150, bbox_inches="tight")
plt.show()


# ----------------------------------------------------------------------------
# [PIE] PIE CHART ET AUTRES GRAPHIQUES
# ----------------------------------------------------------------------------

fig, axes = plt.subplots(1, 3, figsize=(16, 5))
fig.suptitle("Autres Graphiques", fontsize=14, fontweight="bold")

# 1. Pie chart
comptage_filière = df["filière"].value_counts()
explode = [0.05] * len(comptage_filière)
explode[0] = 0.15  # Mettre en valeur la 1ère

wedges, texts, autotexts = axes[0].pie(
    comptage_filière.values,
    labels=comptage_filière.index,
    autopct="%1.1f%%",
    explode=explode,
    colors=["steelblue", "coral", "seagreen", "purple"],
    startangle=90,
    shadow=True
)
for text in autotexts:
    text.set_fontweight("bold")
axes[0].set_title("Répartition par filière")

# 2. Area plot
np.random.seed(42)
mois = range(1, 13)
moy_par_mois_filière = {
    f: np.clip(np.random.normal(13 + i * 0.3, 0.5, 12), 0, 20)
    for i, f in enumerate(["Informatique", "Maths", "Physique"])
}
axes[1].stackplot(
    mois,
    *moy_par_mois_filière.values(),
    labels=list(moy_par_mois_filière.keys()),
    colors=["steelblue", "coral", "seagreen"],
    alpha=0.8
)
axes[1].set_title("Évolution par filière (fictif)")
axes[1].set_xlabel("Mois")
axes[1].set_ylabel("Somme des moyennes")
axes[1].legend(loc="upper left")
axes[1].set_xticks(range(1, 13))

# 3. Heatmap simple avec imshow
corr = df[["note_math", "note_info", "note_science", "heures_étude", "moyenne"]].corr()
im = axes[2].imshow(corr, cmap="coolwarm", vmin=-1, vmax=1, aspect="auto")
plt.colorbar(im, ax=axes[2])
axes[2].set_xticks(range(len(corr.columns)))
axes[2].set_yticks(range(len(corr.columns)))
labels_court = ["Math", "Info", "Sci.", "Heures", "Moy."]
axes[2].set_xticklabels(labels_court, rotation=45)
axes[2].set_yticklabels(labels_court)
for i in range(len(corr)):
    for j in range(len(corr.columns)):
        axes[2].text(j, i, f"{corr.iloc[i, j]:.2f}",
                     ha="center", va="center", fontsize=9)
axes[2].set_title("Corrélations")

plt.tight_layout()
plt.savefig("ch7_autres.png", dpi=150, bbox_inches="tight")
plt.show()


# ----------------------------------------------------------------------------
# [DESIGN] PERSONNALISATION AVANCÉE
# ----------------------------------------------------------------------------

"""
STYLES MATPLOTLIB
"""

# Afficher styles disponibles
# print(plt.style.available)

# Appliquer un style
plt.style.use("seaborn-v0_8-whitegrid")

"""
PALETTES DE COULEURS
"""

# Palettes séquentielles : Blues, Reds, Greens, viridis, plasma, inferno
# Palettes divergentes : coolwarm, RdYlGn, bwr
# Palettes catégorielles : tab10, Set1, Set2, Pastel1

# Extraire couleurs d'une palette
cmap = plt.cm.get_cmap("Set1", 8)
couleurs_palette = [cmap(i) for i in range(8)]

"""
ANNOTATIONS ET TEXTE
"""

fig, ax = plt.subplots(figsize=(10, 6))

# Scatter avec annotations
top5 = df.nlargest(5, "moyenne")[["heures_étude", "moyenne"]].reset_index()
ax.scatter(df["heures_étude"], df["moyenne"], alpha=0.3, color="steelblue", s=30)
ax.scatter(top5["heures_étude"], top5["moyenne"], color="red", s=100, zorder=5, label="Top 5")

# Annoter les top 5
for _, row in top5.iterrows():
    ax.annotate(
        f"#{_+1} ({row['moyenne']:.1f})",
        xy=(row["heures_étude"], row["moyenne"]),
        xytext=(row["heures_étude"] + 2, row["moyenne"] + 0.3),
        fontsize=9,
        arrowprops=dict(arrowstyle="->", color="red", lw=1.5),
        color="red"
    )

# Zones colorées
ax.axhspan(16, 20, alpha=0.1, color="gold", label="Zone Très Bien")
ax.axhspan(14, 16, alpha=0.1, color="lightblue", label="Zone Bien")

ax.set_xlabel("Heures d'étude")
ax.set_ylabel("Moyenne générale")
ax.set_title("Heures d'étude vs Moyenne avec annotations", fontsize=13)
ax.legend()
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig("ch7_annotations.png", dpi=150, bbox_inches="tight")
plt.show()


# ============================================================================
# [GUIDE] CHAPITRE 8 : SEABORN - VISUALISATION STATISTIQUE
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Utiliser Seaborn pour des graphiques statistiques élégants
[OK] Distribution plots (histplot, kdeplot, ecdfplot)
[OK] Categorical plots (boxplot, violinplot, stripplot)
[OK] Relational plots (scatterplot, lineplot)
[OK] Regression plots
[OK] Heatmaps et clustermap
[OK] FacetGrid pour graphiques multiples
"""

sns.set_theme(style="whitegrid", palette="husl", font_scale=1.1)


# ----------------------------------------------------------------------------
# [GRAPHIQUE] GRAPHIQUES DE DISTRIBUTION
# ----------------------------------------------------------------------------

fig, axes = plt.subplots(2, 3, figsize=(18, 10))
fig.suptitle("Seaborn - Graphiques de Distribution", fontsize=14, fontweight="bold")

# 1. histplot (histogramme moderne)
sns.histplot(data=df, x="moyenne", bins=30, kde=True, ax=axes[0, 0], color="steelblue")
axes[0, 0].set_title("histplot avec KDE")

# 2. histplot avec hue (groupes)
sns.histplot(data=df, x="moyenne", hue="bourse", bins=25, kde=True,
             ax=axes[0, 1], alpha=0.6, palette="Set1")
axes[0, 1].set_title("histplot par groupe (hue)")

# 3. kdeplot
sns.kdeplot(data=df, x="moyenne", hue="filière", fill=True,
            ax=axes[0, 2], alpha=0.4, common_norm=False)
axes[0, 2].set_title("kdeplot par filière")

# 4. ecdfplot (Distribution cumulative empirique)
sns.ecdfplot(data=df, x="moyenne", hue="filière", ax=axes[1, 0])
axes[1, 0].set_title("ECDF par filière")
axes[1, 0].axvline(x=10, color="red", ls="--", alpha=0.7)

# 5. qqplot (Q-Q plot - normalité)
from scipy import stats
axes[1, 1].remove()  # Supprimer subplot Seaborn
ax_qq = fig.add_subplot(2, 3, 5)
stats.probplot(df["moyenne"].dropna(), dist="norm", plot=ax_qq)
ax_qq.set_title("Q-Q Plot (Normalité)")

# 6. rugplot
sns.histplot(data=df, x="heures_étude", bins=20, ax=axes[1, 2], color="coral")
sns.rugplot(data=df, x="heures_étude", ax=axes[1, 2], color="red", alpha=0.3)
axes[1, 2].set_title("histplot + rugplot")

plt.tight_layout()
plt.savefig("ch8_distributions.png", dpi=150, bbox_inches="tight")
plt.show()


# ----------------------------------------------------------------------------
# [PACKAGE] GRAPHIQUES CATÉGORIELS
# ----------------------------------------------------------------------------

fig, axes = plt.subplots(2, 3, figsize=(18, 12))
fig.suptitle("Seaborn - Graphiques Catégoriels", fontsize=14, fontweight="bold")

# 1. boxplot
sns.boxplot(data=df, x="filière", y="moyenne", palette="Set2",
            hue="filière", ax=axes[0, 0], legend=False)
axes[0, 0].set_title("Box Plot")
axes[0, 0].tick_params(axis="x", rotation=30)

# 2. violinplot (montre toute la distribution)
sns.violinplot(data=df, x="filière", y="moyenne", palette="Set3",
               hue="filière", ax=axes[0, 1], inner="box", legend=False)
axes[0, 1].set_title("Violin Plot")
axes[0, 1].tick_params(axis="x", rotation=30)

# 3. stripplot (tous les points)
sns.stripplot(data=df, x="filière", y="moyenne", hue="filière",
              ax=axes[0, 2], alpha=0.5, jitter=True, size=4, legend=False)
axes[0, 2].set_title("Strip Plot")
axes[0, 2].tick_params(axis="x", rotation=30)

# 4. Combinaison: boxplot + stripplot
sns.boxplot(data=df, x="ville", y="moyenne", color="white",
            ax=axes[1, 0], fliersize=0)
sns.stripplot(data=df, x="ville", y="moyenne", hue="bourse",
              ax=axes[1, 0], alpha=0.5, size=4, jitter=True,
              palette={"True": "gold", "False": "steelblue"})
axes[1, 0].set_title("Box + Strip par ville")
axes[1, 0].tick_params(axis="x", rotation=30)

# 5. barplot avec IC (Intervalle de Confiance)
sns.barplot(data=df, x="filière", y="moyenne", palette="muted",
            hue="filière", ax=axes[1, 1], errorbar="ci", legend=False)
axes[1, 1].set_title("Bar Plot avec IC 95%")
axes[1, 1].tick_params(axis="x", rotation=30)
axes[1, 1].set_ylabel("Moyenne (± IC 95%)")

# 6. pointplot (version lignes du barplot)
sns.pointplot(data=df, x="filière", y="moyenne", hue="bourse",
              ax=axes[1, 2], palette="Set1", errorbar="sd",
              dodge=True, markers=["o", "s"])
axes[1, 2].set_title("Point Plot par bourse")
axes[1, 2].tick_params(axis="x", rotation=30)

plt.tight_layout()
plt.savefig("ch8_categoriels.png", dpi=150, bbox_inches="tight")
plt.show()


# ----------------------------------------------------------------------------
# [LIEN] GRAPHIQUES RELATIONNELS
# ----------------------------------------------------------------------------

fig, axes = plt.subplots(1, 3, figsize=(18, 6))
fig.suptitle("Seaborn - Graphiques Relationnels", fontsize=14, fontweight="bold")

# 1. scatterplot avec hue et size
sns.scatterplot(
    data=df,
    x="heures_étude",
    y="moyenne",
    hue="filière",
    size="age",           # Taille selon l'âge
    sizes=(20, 150),      # Plage de tailles
    alpha=0.6,
    ax=axes[0],
    palette="Set1"
)
axes[0].set_title("Scatter avec hue et size")

# 2. regplot (scatter + régression)
sns.regplot(
    data=df,
    x="heures_étude",
    y="moyenne",
    ax=axes[1],
    scatter_kws={"alpha": 0.4, "color": "steelblue"},
    line_kws={"color": "red", "lw": 2},
    ci=95  # Intervalle de confiance 95%
)
axes[1].set_title("Scatter + Régression linéaire")

# 3. lmplot - version avancée
# (sur nouvelle figure)
g = sns.lmplot(
    data=df,
    x="heures_étude",
    y="moyenne",
    hue="filière",
    col="bourse",
    height=5,
    scatter_kws={"alpha": 0.5},
    palette="Set1"
)
g.set_titles("Bourse: {col_name}")
plt.savefig("ch8_lmplot.png", dpi=150, bbox_inches="tight")
plt.show()

# lineplot
np.random.seed(42)
temps_data = pd.DataFrame({
    "semaine": list(range(1, 15)) * 4,
    "filière": ["Informatique"] * 14 + ["Maths"] * 14 + ["Physique"] * 14 + ["Chimie"] * 14,
    "note": np.clip(np.concatenate([
        np.cumsum(np.random.randn(14)) + 12,
        np.cumsum(np.random.randn(14)) + 13,
        np.cumsum(np.random.randn(14)) + 11,
        np.cumsum(np.random.randn(14)) + 10
    ]), 0, 20)
})

sns.lineplot(
    data=temps_data,
    x="semaine",
    y="note",
    hue="filière",
    ax=axes[2],
    palette="Set1",
    markers=True,
    dashes=False,
    errorbar="sd"
)
axes[2].set_title("Évolution des notes par filière")
axes[2].set_xlabel("Semaine")
axes[2].set_ylabel("Note")

plt.tight_layout()
plt.savefig("ch8_relationnels.png", dpi=150, bbox_inches="tight")
plt.show()


# ----------------------------------------------------------------------------
# [THERMOMETER] HEATMAPS
# ----------------------------------------------------------------------------

fig, axes = plt.subplots(1, 2, figsize=(14, 6))
fig.suptitle("Seaborn - Heatmaps", fontsize=14, fontweight="bold")

# 1. Heatmap de corrélation
corr = df[["note_math", "note_info", "note_science", "heures_étude", "age", "moyenne"]].corr()

mask = np.triu(np.ones_like(corr, dtype=bool))  # Masque triangle supérieur

sns.heatmap(
    corr,
    ax=axes[0],
    annot=True,
    fmt=".2f",
    cmap="coolwarm",
    center=0,
    vmin=-1, vmax=1,
    mask=mask,            # Afficher seulement triangle inférieur
    linewidths=0.5,
    square=True,
    cbar_kws={"shrink": 0.8}
)
axes[0].set_title("Heatmap de Corrélation (triangle inférieur)")

# 2. Heatmap de fréquence (tableau croisé)
pivot = pd.crosstab(df["filière"], df["mention"])
sns.heatmap(
    pivot,
    ax=axes[1],
    annot=True,
    fmt="d",
    cmap="YlOrRd",
    linewidths=0.5
)
axes[1].set_title("Fréquence Filière × Mention")

plt.tight_layout()
plt.savefig("ch8_heatmaps.png", dpi=150, bbox_inches="tight")
plt.show()


# ----------------------------------------------------------------------------
# [MESURE] FACETGRID ET PAIRPLOT
# ----------------------------------------------------------------------------

"""
FacetGrid : Créer une grille de graphiques similaires
          pour différents groupes

pairplot : Scatter matrix avec distributions sur diagonale
"""

# pairplot
g = sns.pairplot(
    df[["note_math", "note_info", "note_science", "moyenne", "filière"]],
    hue="filière",
    diag_kind="kde",          # Diagonale : KDE
    plot_kws={"alpha": 0.4},
    palette="Set1",
    height=2.5
)
g.fig.suptitle("Pairplot - Relations entre variables", y=1.02)
plt.savefig("ch8_pairplot.png", dpi=150, bbox_inches="tight")
plt.show()

# FacetGrid
g = sns.FacetGrid(df, col="filière", row="bourse",
                  height=3, aspect=1.2, margin_titles=True)
g.map(sns.histplot, "moyenne", bins=20, kde=True, color="steelblue")
g.set_axis_labels("Moyenne", "Fréquence")
g.set_titles(col_template="{col_name}", row_template="Bourse: {row_name}")
g.fig.suptitle("Distribution des moyennes par filière et bourse", y=1.02)
plt.savefig("ch8_facetgrid.png", dpi=150, bbox_inches="tight")
plt.show()


# ============================================================================
# [GUIDE] CHAPITRE 9 : PLOTLY - VISUALISATION INTERACTIVE
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Créer graphiques interactifs avec Plotly
[OK] Utiliser Plotly Express (interface simple)
[OK] Plotly Graph Objects (contrôle avancé)
[OK] Subplots et layouts
[OK] Créer un dashboard simple
"""

try:
    import plotly.express as px
    import plotly.graph_objects as go
    from plotly.subplots import make_subplots
    PLOTLY_AVAILABLE = True
except ImportError:
    PLOTLY_AVAILABLE = False
    print("Plotly non installé. pip install plotly")

if PLOTLY_AVAILABLE:

    # ----------------------------------------------------------------------------
    # [RAPIDE] PLOTLY EXPRESS (Interface Simplifiée)
    # ----------------------------------------------------------------------------

    """
    Plotly Express = Interface haut niveau pour graphiques rapides
    -> Une ligne de code suffit souvent
    -> Interactivité intégrée (zoom, hover, sélection)
    """

    # Scatter interactif
    fig = px.scatter(
        df,
        x="heures_étude",
        y="moyenne",
        color="filière",
        size="age",
        hover_data=["note_math", "note_info", "note_science"],
        title="Heures d'étude vs Moyenne (interactif)",
        template="plotly_white",
        color_discrete_sequence=px.colors.qualitative.Set1
    )
    fig.write_html("scatter_interactif.html")
    fig.show()

    # Histogramme interactif
    fig = px.histogram(
        df,
        x="moyenne",
        color="filière",
        nbins=30,
        barmode="overlay",
        opacity=0.7,
        title="Distribution des moyennes par filière",
        template="plotly_white"
    )
    fig.show()

    # Box plot interactif
    fig = px.box(
        df,
        x="filière",
        y="moyenne",
        color="bourse",
        notched=True,
        points="outliers",
        title="Distribution des moyennes par filière et bourse"
    )
    fig.show()

    # Violin plot
    fig = px.violin(
        df,
        x="filière",
        y="moyenne",
        color="filière",
        box=True,
        points="all",
        title="Violin Plot interactif"
    )
    fig.show()

    # Heatmap corrélation
    corr = df[["note_math", "note_info", "note_science", "heures_étude", "moyenne"]].corr()
    fig = px.imshow(
        corr,
        text_auto=".2f",
        color_continuous_scale="RdBu_r",
        zmin=-1, zmax=1,
        title="Matrice de Corrélation Interactive"
    )
    fig.show()

    # Bar chart animé (si données temporelles)
    fig = px.bar(
        df.groupby("filière")["moyenne"].mean().reset_index(),
        x="filière",
        y="moyenne",
        color="filière",
        title="Moyenne par filière",
        template="plotly_white"
    )
    fig.update_layout(showlegend=False)
    fig.show()

    # ----------------------------------------------------------------------------
    # [OUTIL] PLOTLY GRAPH OBJECTS (Contrôle Avancé)
    # ----------------------------------------------------------------------------

    """
    Graph Objects = Interface bas niveau
    -> Contrôle total sur chaque élément
    -> Plus verbeux mais plus flexible
    """

    # Figure avec subplots
    fig = make_subplots(
        rows=2, cols=2,
        subplot_titles=("Distribution Moyennes", "Scatter",
                        "Box par Filière", "Corrélation"),
        specs=[[{"type": "histogram"}, {"type": "scatter"}],
               [{"type": "box"}, {"type": "heatmap"}]]
    )

    # 1. Histogramme
    fig.add_trace(
        go.Histogram(x=df["moyenne"], nbinsx=30, name="Moyenne",
                     marker_color="steelblue", opacity=0.7),
        row=1, col=1
    )

    # 2. Scatter
    for filière, couleur in zip(df["filière"].unique(), ["blue", "red", "green", "purple"]):
        mask = df["filière"] == filière
        fig.add_trace(
            go.Scatter(
                x=df[mask]["heures_étude"],
                y=df[mask]["moyenne"],
                mode="markers",
                name=filière,
                marker=dict(color=couleur, opacity=0.5, size=6),
            ),
            row=1, col=2
        )

    # 3. Box plot
    for filière in df["filière"].unique():
        fig.add_trace(
            go.Box(y=df[df["filière"] == filière]["moyenne"], name=filière),
            row=2, col=1
        )

    # 4. Heatmap corrélation
    corr = df[["note_math", "note_info", "note_science", "moyenne"]].corr()
    fig.add_trace(
        go.Heatmap(
            z=corr.values,
            x=corr.columns,
            y=corr.columns,
            colorscale="RdBu",
            zmid=0,
            text=corr.round(2).values,
            texttemplate="%{text}",
            showscale=False
        ),
        row=2, col=2
    )

    fig.update_layout(
        title_text="Dashboard - Analyse Étudiants",
        height=700,
        showlegend=False,
        template="plotly_white"
    )

    fig.write_html("dashboard.html")
    fig.show()

    print("Fichiers HTML créés : scatter_interactif.html, dashboard.html")


# ============================================================================
# [GUIDE] CHAPITRE 10 : PANDAS VISUALISATION INTÉGRÉE
# ============================================================================

"""
Pandas peut générer des graphiques directement
-> Pratique pour exploration rapide
-> Basé sur Matplotlib
-> Moins flexible mais très rapide
"""

fig, axes = plt.subplots(2, 3, figsize=(18, 10))
fig.suptitle("Pandas - Visualisation Intégrée", fontsize=14, fontweight="bold")

# 1. DataFrame.plot.bar
df.groupby("filière")["moyenne"].mean().plot.bar(
    ax=axes[0, 0], color="steelblue", alpha=0.8
)
axes[0, 0].set_title("Moyenne par filière (pandas)")
axes[0, 0].tick_params(axis="x", rotation=30)

# 2. DataFrame.plot.line
notes_temps = pd.DataFrame(np.cumsum(np.random.randn(30, 4), axis=0) + 13,
                            columns=["Math", "Info", "Physique", "Chimie"])
notes_temps.plot.line(ax=axes[0, 1])
axes[0, 1].set_title("Évolution des notes")

# 3. DataFrame.plot.hist
df[["note_math", "note_info", "note_science"]].plot.hist(
    bins=25, alpha=0.6, ax=axes[0, 2]
)
axes[0, 2].set_title("Histogrammes superposés")

# 4. DataFrame.plot.box
df[["note_math", "note_info", "note_science"]].plot.box(ax=axes[1, 0])
axes[1, 0].set_title("Box Plots")

# 5. DataFrame.plot.scatter
df.plot.scatter(x="heures_étude", y="moyenne", c="age",
                cmap="viridis", alpha=0.5, ax=axes[1, 1])
axes[1, 1].set_title("Scatter heures vs moyenne")

# 6. DataFrame.plot.area
(df.groupby("ville")[["note_math", "note_info", "note_science"]]
 .mean().plot.bar(stacked=True, ax=axes[1, 2], alpha=0.8))
axes[1, 2].set_title("Notes empilées par ville")
axes[1, 2].tick_params(axis="x", rotation=30)

plt.tight_layout()
plt.savefig("ch10_pandas_plots.png", dpi=150, bbox_inches="tight")
plt.show()


# ============================================================================
# [COURS] EXERCICE PRATIQUE 3 : DASHBOARD COMPLET
# ============================================================================

"""
OBJECTIF : Créer un rapport visuel complet d'analyse

CAHIER DES CHARGES :
1. Vue d'ensemble (statistiques clés)
2. Distribution des notes
3. Comparaisons par filière
4. Analyse des corrélations
5. Performances par ville
6. Répartition des mentions

SOLUTION COMPLÈTE :
"""

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
import seaborn as sns
from scipy import stats
warnings.filterwarnings("ignore")

np.random.seed(42)

# Recréer le dataset
n = 500
df_dash = pd.DataFrame({
    "filière": np.random.choice(["Informatique", "Maths", "Physique", "Chimie"], n,
                                 p=[0.4, 0.25, 0.2, 0.15]),
    "ville": np.random.choice(["Paris", "Lyon", "Marseille", "Bordeaux"], n,
                               p=[0.35, 0.3, 0.2, 0.15]),
    "age": np.random.randint(18, 28, n),
    "note_math": np.clip(np.random.normal(13, 3, n), 0, 20).round(1),
    "note_info": np.clip(np.random.normal(14, 2.5, n), 0, 20).round(1),
    "note_science": np.clip(np.random.normal(12, 3.5, n), 0, 20).round(1),
    "heures_étude": np.clip(np.random.normal(20, 8, n), 0, 50).round(0),
    "bourse": np.random.choice([True, False], n, p=[0.3, 0.7])
})
df_dash["moyenne"] = df_dash[["note_math", "note_info", "note_science"]].mean(axis=1).round(2)
df_dash["mention"] = pd.cut(df_dash["moyenne"],
                             bins=[0, 10, 12, 14, 16, 20],
                             labels=["Insuf.", "Passable", "Assez B.", "Bien", "Très B."])

# ==========================================
# FIGURE PRINCIPALE - DASHBOARD
# ==========================================
fig = plt.figure(figsize=(20, 24))
fig.patch.set_facecolor("#F8F9FA")

# Titre principal
fig.text(0.5, 0.98, "[GRAPHIQUE] TABLEAU DE BORD - ANALYSE DES ÉTUDIANTS",
         ha="center", va="top", fontsize=18, fontweight="bold", color="#2C3E50")
fig.text(0.5, 0.965, f"Dataset : {n} étudiants | Généré avec Matplotlib & Seaborn",
         ha="center", va="top", fontsize=11, color="#7F8C8D")

# GridSpec pour layout flexible
gs = gridspec.GridSpec(4, 3, figure=fig, hspace=0.45, wspace=0.35,
                       top=0.95, bottom=0.02, left=0.05, right=0.98)

# -----------------------------------------------
# RANGÉE 1 : MÉTRIQUES CLÉS (3 panneaux)
# -----------------------------------------------
palette = sns.color_palette("husl", 8)

# KPIs sous forme de texte dans des boîtes
métriques = [
    ("Nb Étudiants", str(n), "#3498DB"),
    ("Moyenne Générale", f"{df_dash['moyenne'].mean():.2f}/20", "#2ECC71"),
    ("Taux de Réussite", f"{(df_dash['moyenne'] >= 10).mean()*100:.1f}%", "#E74C3C"),
]

for i, (label, valeur, couleur) in enumerate(métriques):
    ax = fig.add_subplot(gs[0, i])
    ax.set_facecolor(couleur)
    ax.text(0.5, 0.6, valeur, ha="center", va="center",
            fontsize=22, fontweight="bold", color="white", transform=ax.transAxes)
    ax.text(0.5, 0.2, label, ha="center", va="center",
            fontsize=12, color="white", transform=ax.transAxes)
    ax.set_xticks([])
    ax.set_yticks([])
    for spine in ax.spines.values():
        spine.set_visible(False)

# -----------------------------------------------
# RANGÉE 2 : DISTRIBUTIONS (3 panneaux)
# -----------------------------------------------

# Distribution générale
ax2a = fig.add_subplot(gs[1, 0])
ax2a.hist(df_dash["moyenne"], bins=35, density=True, color="#3498DB", alpha=0.7, edgecolor="white")
kde_x = np.linspace(df_dash["moyenne"].min(), df_dash["moyenne"].max(), 200)
kde = stats.gaussian_kde(df_dash["moyenne"])
ax2a.plot(kde_x, kde(kde_x), "r-", lw=2.5)
ax2a.axvline(df_dash["moyenne"].mean(), color="green", ls="--", lw=1.5,
             label=f"Moy: {df_dash['moyenne'].mean():.2f}")
ax2a.axvline(df_dash["moyenne"].median(), color="orange", ls="--", lw=1.5,
             label=f"Méd: {df_dash['moyenne'].median():.2f}")
ax2a.set_title("Distribution des Moyennes", fontweight="bold")
ax2a.legend(fontsize=9)
ax2a.set_xlabel("Moyenne")
ax2a.set_ylabel("Densité")

# Box plots par filière
ax2b = fig.add_subplot(gs[1, 1])
données_filière = [df_dash[df_dash["filière"] == f]["moyenne"].values
                   for f in df_dash["filière"].unique()]
bp = ax2b.boxplot(données_filière, patch_artist=True, labels=df_dash["filière"].unique())
couleurs_f = ["#3498DB", "#E74C3C", "#2ECC71", "#9B59B6"]
for patch, c in zip(bp["boxes"], couleurs_f):
    patch.set_facecolor(c)
    patch.set_alpha(0.7)
ax2b.set_title("Moyennes par Filière", fontweight="bold")
ax2b.set_ylabel("Moyenne")
ax2b.tick_params(axis="x", rotation=30)

# Répartition mentions (pie)
ax2c = fig.add_subplot(gs[1, 2])
mention_counts = df_dash["mention"].value_counts()
couleurs_ment = ["#E74C3C", "#F39C12", "#F1C40F", "#2ECC71", "#27AE60"]
wedges, texts, auto = ax2c.pie(
    mention_counts.values,
    labels=mention_counts.index,
    autopct="%1.1f%%",
    colors=couleurs_ment[:len(mention_counts)],
    startangle=90
)
ax2c.set_title("Répartition des Mentions", fontweight="bold")

# -----------------------------------------------
# RANGÉE 3 : ANALYSES COMPARATIVES
# -----------------------------------------------

# Barres moyennes par ville
ax3a = fig.add_subplot(gs[2, 0])
moy_ville = df_dash.groupby("ville")["moyenne"].mean().sort_values(ascending=True)
bars = ax3a.barh(moy_ville.index, moy_ville.values,
                 color=["#3498DB", "#E74C3C", "#2ECC71", "#9B59B6"], alpha=0.8)
ax3a.axvline(df_dash["moyenne"].mean(), color="red", ls="--", lw=1.5)
for bar, val in zip(bars, moy_ville.values):
    ax3a.text(val + 0.05, bar.get_y() + bar.get_height() / 2,
              f"{val:.2f}", va="center", fontsize=10)
ax3a.set_title("Moyenne par Ville", fontweight="bold")
ax3a.set_xlabel("Moyenne")

# Scatter heures vs moyenne
ax3b = fig.add_subplot(gs[2, 1])
scatter_couleurs = {"Informatique": "#3498DB", "Maths": "#E74C3C",
                    "Physique": "#2ECC71", "Chimie": "#9B59B6"}
for filière, c in scatter_couleurs.items():
    mask = df_dash["filière"] == filière
    ax3b.scatter(df_dash[mask]["heures_étude"], df_dash[mask]["moyenne"],
                 c=c, alpha=0.4, s=25, label=filière)
z = np.polyfit(df_dash["heures_étude"], df_dash["moyenne"], 1)
p = np.poly1d(z)
x_t = np.linspace(df_dash["heures_étude"].min(), df_dash["heures_étude"].max(), 100)
ax3b.plot(x_t, p(x_t), "k--", lw=2)
r, _ = stats.pearsonr(df_dash["heures_étude"], df_dash["moyenne"])
ax3b.set_title(f"Heures d'étude vs Moyenne (r={r:.2f})", fontweight="bold")
ax3b.set_xlabel("Heures d'étude")
ax3b.set_ylabel("Moyenne")
ax3b.legend(fontsize=8)

# Heatmap corrélation
ax3c = fig.add_subplot(gs[2, 2])
cols_corr = ["note_math", "note_info", "note_science", "heures_étude", "moyenne"]
corr = df_dash[cols_corr].corr()
mask = np.triu(np.ones_like(corr, dtype=bool), k=1)
im = ax3c.imshow(corr, cmap="coolwarm", vmin=-1, vmax=1, aspect="auto")
labels_c = ["Math", "Info", "Sci.", "Heures", "Moy."]
ax3c.set_xticks(range(len(labels_c)))
ax3c.set_yticks(range(len(labels_c)))
ax3c.set_xticklabels(labels_c, rotation=45)
ax3c.set_yticklabels(labels_c)
for i in range(len(corr)):
    for j in range(len(corr.columns)):
        if i >= j:
            ax3c.text(j, i, f"{corr.iloc[i, j]:.2f}",
                      ha="center", va="center", fontsize=9, fontweight="bold")
ax3c.set_title("Corrélations", fontweight="bold")
plt.colorbar(im, ax=ax3c, fraction=0.046, pad=0.04)

# -----------------------------------------------
# RANGÉE 4 : COMPARAISONS AVANCÉES
# -----------------------------------------------

# Notes par matière et filière (grouped bar)
ax4a = fig.add_subplot(gs[3, 0:2])
moy_matières = df_dash.groupby("filière")[["note_math", "note_info", "note_science"]].mean()
x = np.arange(len(moy_matières.index))
w = 0.25
b1 = ax4a.bar(x - w, moy_matières["note_math"], w, label="Maths",
              color="#3498DB", alpha=0.8, edgecolor="black", linewidth=0.5)
b2 = ax4a.bar(x, moy_matières["note_info"], w, label="Info",
              color="#E74C3C", alpha=0.8, edgecolor="black", linewidth=0.5)
b3 = ax4a.bar(x + w, moy_matières["note_science"], w, label="Science",
              color="#2ECC71", alpha=0.8, edgecolor="black", linewidth=0.5)
ax4a.set_xticks(x)
ax4a.set_xticklabels(moy_matières.index)
ax4a.legend()
ax4a.set_title("Notes Moyennes par Matière et Filière", fontweight="bold")
ax4a.set_ylabel("Note")
ax4a.axhline(y=10, color="red", ls=":", alpha=0.5)

# Boursiers vs Non-boursiers
ax4b = fig.add_subplot(gs[3, 2])
bourse_data = df_dash.groupby(["filière", "bourse"])["moyenne"].mean().reset_index()
bourse_data["bourse_label"] = bourse_data["bourse"].map({True: "Boursier", False: "Non-bours."})
filières_u = bourse_data["filière"].unique()
x = np.arange(len(filières_u))
for j, (bourse_val, label, c) in enumerate([(True, "Boursier", "#F1C40F"),
                                              (False, "Non-bours.", "#95A5A6")]):
    vals = [bourse_data[(bourse_data["filière"] == f) &
                        (bourse_data["bourse"] == bourse_val)]["moyenne"].values
            for f in filières_u]
    vals = [v[0] if len(v) > 0 else 0 for v in vals]
    ax4b.bar(x + (j - 0.5) * 0.35, vals, 0.35, label=label, color=c, alpha=0.8)
ax4b.set_xticks(x)
ax4b.set_xticklabels(filières_u, rotation=30)
ax4b.legend()
ax4b.set_title("Impact de la Bourse", fontweight="bold")
ax4b.set_ylabel("Moyenne")

# Sauvegarde
plt.savefig("dashboard_complet.png", dpi=150, bbox_inches="tight",
            facecolor=fig.get_facecolor())
print("[OK] Dashboard sauvegardé : dashboard_complet.png")
plt.show()

# ============================================================================
# [DOCS] RÉCAPITULATIF PARTIE 3
# ============================================================================

"""
[BRAVO] FÉLICITATIONS ! PARTIE 3 TERMINÉE !

VOUS MAÎTRISEZ MAINTENANT :

Chapitre 7 : Matplotlib
[OK] Architecture (Figure, Axes, Axis)
[OK] Line plot, histogramme, scatter, bar, pie
[OK] Personnalisation (couleurs, styles, annotations)
[OK] Subplots et GridSpec
[OK] Sauvegarde haute qualité

Chapitre 8 : Seaborn
[OK] Graphiques de distribution (histplot, kdeplot, ecdfplot)
[OK] Graphiques catégoriels (boxplot, violinplot, barplot)
[OK] Graphiques relationnels (scatterplot, lmplot)
[OK] Heatmaps
[OK] PairPlot et FacetGrid

Chapitre 9 : Plotly
[OK] Plotly Express (graphiques rapides)
[OK] Graph Objects (contrôle avancé)
[OK] Graphiques interactifs (scatter, box, histogram)
[OK] Dashboard HTML

Chapitre 10 : Pandas Plots
[OK] df.plot.bar(), hist(), scatter(), box()


[CLE] CHOISIR LE BON OUTIL

MATPLOTLIB  -> Contrôle total, publications, personnalisation max
SEABORN     -> Stats, groupes, élégant, rapide
PLOTLY      -> Interactivité, dashboards, web
PANDAS PLOT -> Exploration rapide, 1 ligne de code


[CLE] CHOISIR LE BON GRAPHIQUE

Comparer catégories    -> Bar chart
Distribution           -> Histogramme + KDE
Relation 2 variables   -> Scatter plot
Évolution temporelle   -> Line plot
Distribution + groupes -> Box plot / Violin plot
Corrélations           -> Heatmap
Proportions            -> Pie chart (< 5 catégories)


-> PROCHAINE ÉTAPE : PARTIE 4 - STATISTIQUES

Vous allez apprendre :
- Tests statistiques (t-test, ANOVA, chi-carré)
- Régression et corrélation
- Analyse de séries temporelles
- Intervalles de confiance

Prêt pour les statistiques sérieuses ? [MESURE]
"""

# ============================================================================
# [LIVRE] ANALYSE DE DONNÉES AVEC PYTHON
# PARTIE 4 : STATISTIQUES ET ANALYSE
# ============================================================================
#
# [OBJECTIF] CETTE PARTIE COUVRE :
# - Chapitre 11 : Statistiques Descriptives
# - Chapitre 12 : Statistiques Inférentielles
# - Chapitre 13 : Corrélation et Régression
# - Chapitre 14 : Analyse Temporelle (Time Series)
#
# [TEMPS] TEMPS : ~8-10 heures
# [DOCS] PRÉREQUIS : Parties 1, 2 et 3 complétées
# ============================================================================

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats
from scipy.stats import (
    norm, t, chi2, f,
    ttest_1samp, ttest_ind, ttest_rel,
    mannwhitneyu, wilcoxon, kruskal,
    chi2_contingency, fisher_exact,
    f_oneway, pearsonr, spearmanr, kendalltau,
    shapiro, normaltest, kstest
)
import statsmodels.api as sm
import statsmodels.formula.api as smf
from statsmodels.stats.multicomp import pairwise_tukeyhsd
import warnings
warnings.filterwarnings("ignore")

# Configuration des graphiques
sns.set_theme(style="whitegrid", font_scale=1.1)
plt.rcParams["figure.dpi"] = 100

# Dataset principal réutilisé dans tout le chapitre
np.random.seed(42)
n = 500

df = pd.DataFrame({
    "filière": np.random.choice(
        ["Informatique", "Maths", "Physique", "Chimie"], n,
        p=[0.40, 0.25, 0.20, 0.15]
    ),
    "ville": np.random.choice(
        ["Paris", "Lyon", "Marseille", "Bordeaux"], n,
        p=[0.35, 0.30, 0.20, 0.15]
    ),
    "sexe": np.random.choice(["H", "F"], n, p=[0.55, 0.45]),
    "age": np.random.randint(18, 28, n),
    "note_math": np.clip(np.random.normal(13, 3, n), 0, 20).round(1),
    "note_info": np.clip(np.random.normal(14, 2.5, n), 0, 20).round(1),
    "note_science": np.clip(np.random.normal(12, 3.5, n), 0, 20).round(1),
    "heures_étude": np.clip(np.random.normal(20, 8, n), 0, 50).round(0),
    "bourse": np.random.choice([True, False], n, p=[0.30, 0.70]),
    "satisfaction": np.random.randint(1, 6, n),
})
df["moyenne"] = df[["note_math", "note_info", "note_science"]].mean(axis=1).round(2)


# ============================================================================
# [GUIDE] CHAPITRE 11 : STATISTIQUES DESCRIPTIVES
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Calculer et interpréter toutes les mesures de tendance centrale
[OK] Calculer et interpréter toutes les mesures de dispersion
[OK] Comprendre les percentiles et quartiles
[OK] Analyser la forme d'une distribution (skewness, kurtosis)
[OK] Construire des tableaux croisés et des rapports statistiques
[OK] Détecter les valeurs aberrantes avec méthodes robustes
"""


# ----------------------------------------------------------------------------
# [MESURE] MESURES DE TENDANCE CENTRALE
# ----------------------------------------------------------------------------

"""
LES 3 GRANDES MESURES DE TENDANCE CENTRALE

1. MOYENNE (Mean) -> Centre de gravité des données
2. MÉDIANE (Median) -> Valeur centrale (50ème percentile)
3. MODE (Mode) -> Valeur la plus fréquente

QUAND UTILISER QUOI ?

Données NORMALES (sans outliers) -> Moyenne
Données ASYMÉTRIQUES ou avec outliers -> Médiane
Données CATÉGORIELLES -> Mode

ANALOGIE : SALAIRES DANS UNE ENTREPRISE
- 9 employés gagnent 2 000€
- 1 PDG gagne 100 000€

Moyenne : (9×2000 + 100000) / 10 = 11 800€  <- Trompeuse !
Médiane : 2 000€  <- Représentative de la réalité
Mode    : 2 000€  <- Valeur la plus commune
"""

notes = df["moyenne"].dropna()

# Moyenne arithmétique
moyenne = notes.mean()
print(f"Moyenne : {moyenne:.3f}")

# Moyenne pondérée
poids = [0.4, 0.35, 0.25]  # Math, Info, Science
moy_pond = (df["note_math"] * poids[0] +
            df["note_info"] * poids[1] +
            df["note_science"] * poids[2])
print(f"Moyenne pondérée : {moy_pond.mean():.3f}")

# Moyenne géométrique (pour taux de croissance)
from scipy.stats import gmean
moy_geo = gmean(notes[notes > 0])
print(f"Moyenne géométrique : {moy_geo:.3f}")

# Moyenne harmonique (pour vitesses, ratios)
from scipy.stats import hmean
moy_harm = hmean(notes[notes > 0])
print(f"Moyenne harmonique : {moy_harm:.3f}")

# Médiane
mediane = notes.median()
print(f"Médiane : {mediane:.3f}")

# Mode
mode_result = notes.mode()
print(f"Mode : {mode_result.values}")

# Comparaison sur données asymétriques
print("\n" + "="*60)
print("IMPACT DES OUTLIERS SUR LES MESURES")
print("="*60)

salaires = pd.Series([2000, 2100, 1900, 2200, 2050, 1950, 2000, 2100, 1850, 100000])
print(f"Moyenne   : {salaires.mean():>10,.0f}€  <- BIAISÉE par le PDG")
print(f"Médiane   : {salaires.median():>10,.0f}€  <- ROBUSTE, représentative")
print(f"Mode      : {salaires.mode()[0]:>10,.0f}€")
print(f"Moyenne ém. : {stats.trim_mean(salaires, 0.1):>10,.0f}€  <- Trimmée 10%")


# ----------------------------------------------------------------------------
# [GRAPHIQUE] MESURES DE DISPERSION
# ----------------------------------------------------------------------------

"""
LES MESURES DE DISPERSION

POURQUOI LA DISPERSION ?
-> Deux distributions peuvent avoir la même moyenne
  mais des dispersions très différentes !

Ex : Équipe A : [10, 10, 10, 10, 10] -> Moy=10, σ=0
     Équipe B : [2, 5, 10, 15, 18]  -> Moy=10, σ=5.8

Mêmes moyennes, comportements très différents !


LES MESURES CLÉS

1. VARIANCE (σ²) -> Moyenne des écarts au carré
   σ² = Σ(xi - µ)² / N

2. ÉCART-TYPE (σ) -> Racine carrée de la variance
   σ = √σ²
   -> MÊME UNITÉ que les données

3. ÉTENDUE -> Max - Min
   -> Simple mais sensible aux outliers

4. IQR (Interquartile Range) -> Q3 - Q1
   -> Robuste aux outliers
   -> Contient le "cœur" des données (50%)

5. MAD (Median Absolute Deviation) -> Très robuste
   MAD = médiane(|xi - médiane|)

6. COEFFICIENT DE VARIATION (CV) -> Dispersion relative
   CV = σ / µ × 100%
   -> Comparer des variables d'unités différentes
"""

print("\n" + "="*60)
print("MESURES DE DISPERSION - NOTE MATH")
print("="*60)

data = df["note_math"].dropna()

variance = data.var()
ecart_type = data.std()
etendue = data.max() - data.min()
iqr = data.quantile(0.75) - data.quantile(0.25)
mad = np.median(np.abs(data - data.median()))
cv = (ecart_type / data.mean()) * 100

print(f"Variance      : {variance:.3f}")
print(f"Écart-type    : {ecart_type:.3f}")
print(f"Étendue       : {etendue:.3f}")
print(f"IQR           : {iqr:.3f}")
print(f"MAD           : {mad:.3f}")
print(f"Coeff. Var.   : {cv:.1f}%")

"""
INTERPRÉTATION DU COEFFICIENT DE VARIATION

CV < 15%  -> Faible dispersion (données homogènes)
15% < CV < 35%  -> Dispersion modérée
CV > 35%  -> Forte dispersion (données hétérogènes)
"""

print("\nComparaison des dispersions :")
for col in ["note_math", "note_info", "note_science", "heures_étude"]:
    d = df[col].dropna()
    cv_val = (d.std() / d.mean()) * 100
    iqr_val = d.quantile(0.75) - d.quantile(0.25)
    print(f"{col:20} | σ={d.std():.2f} | CV={cv_val:.1f}% | IQR={iqr_val:.2f}")


# ----------------------------------------------------------------------------
# [HAUSSE] PERCENTILES ET QUARTILES
# ----------------------------------------------------------------------------

"""
PERCENTILES ET QUANTILES

Le pème percentile = Valeur en dessous de laquelle
                     se trouvent p% des observations

QUARTILES COURANTS :
Q1 = P25 -> 25% des données sont en dessous
Q2 = P50 -> Médiane (50% en dessous)
Q3 = P75 -> 75% des données sont en dessous

IQR = Q3 - Q1 = "Boîte centrale" contenant 50% des données

UTILISATION :
- Percentile 90 en vitesse internet -> Meilleures 10%
- Percentile 10 en salaire -> Bas de l'échelle
- Percentile 95 en latence web -> Performances critiques
"""

print("\n" + "="*60)
print("ANALYSE DES PERCENTILES")
print("="*60)

percentiles = [5, 10, 25, 50, 75, 90, 95, 99]
for p in percentiles:
    val = np.percentile(df["moyenne"].dropna(), p)
    print(f"P{p:2d} : {val:.2f}")

# Five-number summary (Tukey)
print("\nFIVE-NUMBER SUMMARY (Tukey) :")
print(f"Minimum  : {df['moyenne'].min():.2f}")
print(f"Q1       : {df['moyenne'].quantile(0.25):.2f}")
print(f"Médiane  : {df['moyenne'].median():.2f}")
print(f"Q3       : {df['moyenne'].quantile(0.75):.2f}")
print(f"Maximum  : {df['moyenne'].max():.2f}")


# ----------------------------------------------------------------------------
# [SYNC] FORME DE LA DISTRIBUTION
# ----------------------------------------------------------------------------

"""
ASYMÉTRIE (SKEWNESS) ET APLATISSEMENT (KURTOSIS)

ASYMÉTRIE (Skewness) :
Mesure l'asymétrie de la distribution

Skewness > 0 : Queue à droite
               -> Valeurs élevées rares mais existantes
               -> Ex: Revenus, prix immobilier

Skewness < 0 : Queue à gauche
               -> Valeurs faibles rares mais existantes
               -> Ex: Âge au décès dans pays développés

Skewness ≈ 0 : Distribution symétrique
               -> Souvent normale

Règle : |Skewness| < 0.5 -> Approximativement symétrique
        |Skewness| > 1.0 -> Très asymétrique


APLATISSEMENT (Kurtosis) :
Mesure "l'épaisseur des queues" par rapport à la normale

Kurtosis > 3 (Leptokurtique) :
  -> Pics pointus, queues épaisses
  -> Plus de valeurs extrêmes que la normale
  -> Ex: Rendements financiers

Kurtosis < 3 (Platykurtique) :
  -> Pics aplatis, queues minces
  -> Moins de valeurs extrêmes que la normale

Kurtosis = 3 (Mésokurtique) :
  -> Comme la distribution normale
  -> scipy retourne l'excess kurtosis : kurtosis - 3
"""

print("\n" + "="*60)
print("FORME DES DISTRIBUTIONS")
print("="*60)

for col in ["note_math", "note_info", "note_science", "heures_étude", "moyenne"]:
    data = df[col].dropna()
    skew = stats.skew(data)
    kurt = stats.kurtosis(data)  # Excess kurtosis (kurtosis - 3)

    skew_interp = "Sym." if abs(skew) < 0.5 else ("Droite" if skew > 0 else "Gauche")
    kurt_interp = "Normal" if abs(kurt) < 1 else ("Lepto" if kurt > 0 else "Platy")

    print(f"{col:20} | Skewness: {skew:+.3f} ({skew_interp:6}) | Kurtosis: {kurt:+.3f} ({kurt_interp})")


# ----------------------------------------------------------------------------
# [LISTE] TABLEAU CROISÉ ET STATISTIQUES GROUPÉES
# ----------------------------------------------------------------------------

"""
TABLEAU CROISÉ (Crosstabulation / Contingency Table)

POURQUOI :
-> Analyser la relation entre 2 variables catégorielles
-> Voir comment les catégories se croisent

TYPES DE NORMALISATION :
- Fréquences absolues (brutes)
- Fréquences relatives par ligne
- Fréquences relatives par colonne
- Fréquences relatives globales
"""

# Créer variable mention
df["mention"] = pd.cut(
    df["moyenne"],
    bins=[0, 10, 12, 14, 16, 20],
    labels=["Insuf.", "Passable", "Assez B.", "Bien", "Très B."]
)

print("\n" + "="*60)
print("TABLEAUX CROISÉS : FILIÈRE × MENTION")
print("="*60)

# Tableau de fréquences absolues
tab_abs = pd.crosstab(df["filière"], df["mention"], margins=True)
print("\nFréquences absolues :")
print(tab_abs)

# Tableau de fréquences relatives (% par ligne)
tab_rel_ligne = pd.crosstab(df["filière"], df["mention"],
                            normalize="index") * 100
print("\nFréquences relatives par ligne (%) :")
print(tab_rel_ligne.round(1))

# Tableau de fréquences relatives (% par colonne)
tab_rel_col = pd.crosstab(df["filière"], df["mention"],
                           normalize="columns") * 100
print("\nFréquences relatives par colonne (%) :")
print(tab_rel_col.round(1))


# ----------------------------------------------------------------------------
# [GRAPHIQUE] RAPPORT STATISTIQUE AUTOMATISÉ
# ----------------------------------------------------------------------------

def rapport_statistiques_complet(df, colonnes_num, colonnes_cat=None, titre="Rapport"):
    """
    Génère un rapport statistique complet.

    COMMENT :
    1. Statistiques descriptives numériques
    2. Analyse par groupe si colonnes catégorielles spécifiées
    3. Corrélations entre variables numériques

    POURQUOI :
    Standardiser et accélérer l'analyse exploratoire
    """
    print("=" * 70)
    print(f"[GRAPHIQUE] {titre.upper()}")
    print("=" * 70)

    # 1. Statistiques descriptives numériques
    print("\n── 1. STATISTIQUES DESCRIPTIVES ──")
    for col in colonnes_num:
        data = df[col].dropna()
        print(f"\n  {col} (n={len(data)}, nan={df[col].isnull().sum()})")
        print(f"    Tendance    : Moy={data.mean():.2f} | Med={data.median():.2f} | Mode={data.mode().iloc[0]:.2f}")
        print(f"    Dispersion  : σ={data.std():.2f} | IQR={data.quantile(0.75)-data.quantile(0.25):.2f} | CV={data.std()/data.mean()*100:.1f}%")
        print(f"    Étendue     : [{data.min():.2f}, {data.max():.2f}]")
        print(f"    Forme       : Skew={stats.skew(data):.3f} | Kurt={stats.kurtosis(data):.3f}")
        print(f"    Percentiles : P25={data.quantile(0.25):.2f} | P50={data.quantile(0.50):.2f} | P75={data.quantile(0.75):.2f} | P95={data.quantile(0.95):.2f}")

    # 2. Analyse par groupes
    if colonnes_cat:
        print("\n── 2. ANALYSE PAR GROUPES ──")
        for cat in colonnes_cat:
            print(f"\n  Groupé par '{cat}' :")
            groupe = df.groupby(cat)[colonnes_num].agg(["mean", "std", "count"]).round(2)
            print(groupe.to_string())

    # 3. Corrélations
    if len(colonnes_num) >= 2:
        print("\n── 3. CORRÉLATIONS ──")
        corr = df[colonnes_num].corr()
        for i, col1 in enumerate(colonnes_num):
            for j, col2 in enumerate(colonnes_num):
                if i < j:
                    r = corr.loc[col1, col2]
                    if abs(r) > 0.3:
                        force = "forte" if abs(r) > 0.7 else "modérée" if abs(r) > 0.5 else "faible"
                        sens = "positive" if r > 0 else "négative"
                        print(f"  {col1} <-> {col2}: r={r:.3f} ({force} {sens})")

    print("\n" + "="*70)


rapport_statistiques_complet(
    df,
    colonnes_num=["note_math", "note_info", "note_science", "heures_étude", "moyenne"],
    colonnes_cat=["filière", "sexe"],
    titre="Analyse complète des étudiants"
)


# ============================================================================
# [GUIDE] CHAPITRE 12 : STATISTIQUES INFÉRENTIELLES
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Comprendre les hypothèses statistiques (H0, H1)
[OK] Interpréter la p-value et les erreurs de type I/II
[OK] Réaliser des tests paramétriques (t-test, ANOVA)
[OK] Réaliser des tests non-paramétriques (Mann-Whitney, Kruskal-Wallis)
[OK] Calculer les intervalles de confiance
[OK] Vérifier les conditions d'application des tests
[OK] Calculer la puissance statistique et taille d'effet
"""


# ----------------------------------------------------------------------------
# [OBJECTIF] INTRODUCTION : LOGIQUE DES TESTS STATISTIQUES
# ----------------------------------------------------------------------------

"""
LA LOGIQUE FONDAMENTALE DES TESTS STATISTIQUES

HYPOTHÈSES :
H0 (Hypothèse nulle) -> Pas d'effet, pas de différence
H1 (Hypothèse alternative) -> Il y a un effet, une différence

LE TEST STATISTIQUE RÉPOND À LA QUESTION :
"Si H0 est vraie, quelle est la probabilité d'observer
 des données aussi extrêmes ou plus extrêmes ?"

C'est la p-value !

DÉCISION :
Si p-value < α (seuil de signification, souvent 0.05) :
    -> Rejeter H0
    -> Les données sont significativement différentes

Si p-value ≥ α :
    -> Ne pas rejeter H0 (faute de preuves suffisantes)
    -> Attention : ≠ accepter H0 !


INTERPRÉTATION DE LA P-VALUE

p = 0.001 -> Si H0 est vraie, on observerait ces données
             dans 0.1% des cas -> Très rare -> Rejeter H0
p = 0.03  -> Dans 3% des cas -> Rejeter H0 (< 0.05)
p = 0.15  -> Dans 15% des cas -> Pas assez rare -> Conserver H0
p = 0.80  -> Dans 80% des cas -> Très commun -> Conserver H0


ERREURS POSSIBLES

                    Réalité
                  H0 vraie  |  H1 vraie
               ─────────────────────────
Décision  Rejeter  | Erreur I  | Décision [OK]
          Garder   | Décision [OK]| Erreur II

Erreur de Type I (α) : Rejeter H0 alors qu'elle est vraie
                        "Faux positif"
                        -> Contrôlé par le seuil α

Erreur de Type II (β) : Garder H0 alors qu'elle est fausse
                         "Faux négatif"
                         -> Lié à la puissance (1-β)


ANALOGIE :
Test médical pour une maladie :
- H0 : Patient sain
- H1 : Patient malade

Erreur Type I : Diagnostiquer une maladie à un patient sain
                (Faux positif -> Traitement inutile)
Erreur Type II : Rater le diagnostic chez un patient malade
                 (Faux négatif -> Maladie non traitée)


[ATTENTION] PIÈGES COURANTS

1. p-value < 0.05 ≠ "Important en pratique"
   -> Utiliser la taille d'effet (Cohen's d, η²)

2. p-value > 0.05 ≠ "H0 prouvée"
   -> "Absence de preuve ≠ Preuve d'absence"

3. Tests multiples -> Correction de Bonferroni
   -> Avec 20 tests au seuil 0.05, on s'attend à 1 faux positif !

4. Significatif statistiquement ≠ Significatif pratiquement
   -> Avec un grand n, même de très petites différences sont "significatives"
"""


# ----------------------------------------------------------------------------
# [OK] VÉRIFICATION DES CONDITIONS : TESTS DE NORMALITÉ
# ----------------------------------------------------------------------------

"""
TESTS DE NORMALITÉ

Pourquoi important ?
-> Les tests paramétriques (t-test, ANOVA) supposent la normalité
-> Si non-normale -> Tests non-paramétriques

TESTS DISPONIBLES :

1. TEST DE SHAPIRO-WILK
   -> Plus puissant pour petits échantillons (n < 50)
   -> H0 : Les données suivent une loi normale
   -> Si p < 0.05 -> NON normale

2. TEST DE D'AGOSTINO-PEARSON
   -> Bon pour n > 20
   -> Basé sur skewness et kurtosis

3. TEST DE KOLMOGOROV-SMIRNOV
   -> Compare à une distribution théorique
   -> Moins puissant que Shapiro

REMARQUE PRATIQUE :
Pour grands échantillons (n > 200) :
-> Ces tests détectent des déviations infimes de la normalité
-> Inspecter les graphiques (Q-Q plot, histogramme)
-> Les tests paramétriques sont souvent robustes (Théorème Central Limite)
"""

print("\n" + "="*60)
print("TESTS DE NORMALITÉ")
print("="*60)

for col in ["note_math", "note_info", "note_science", "heures_étude"]:
    data = df[col].dropna()
    sample = data.sample(min(200, len(data)), random_state=42)  # Shapiro recommande n<5000

    # Test de Shapiro-Wilk
    stat_sw, p_sw = shapiro(sample)

    # Test de D'Agostino-Pearson
    stat_dp, p_dp = normaltest(sample)

    conclusion = "Normale [OK]" if p_sw > 0.05 else "Non-normale [ATTENTION]"
    print(f"\n{col}:")
    print(f"  Shapiro-Wilk     : W={stat_sw:.4f}, p={p_sw:.6f} -> {conclusion}")
    print(f"  D'Agostino-Pearson: stat={stat_dp:.4f}, p={p_dp:.6f}")

# Visualisation Q-Q Plots
fig, axes = plt.subplots(2, 2, figsize=(12, 10))
fig.suptitle("Q-Q Plots (Test de Normalité Visuel)", fontsize=14, fontweight="bold")

for ax, col in zip(axes.flatten(), ["note_math", "note_info", "note_science", "heures_étude"]):
    data = df[col].dropna()
    stats.probplot(data, dist="norm", plot=ax)
    ax.set_title(f"{col}", fontweight="bold")
    # Si les points suivent la ligne rouge -> Distribution normale

plt.tight_layout()
plt.savefig("ch12_qqplots.png", dpi=150, bbox_inches="tight")
plt.show()
print("\nQ-Q Plot sauvegardé : ch12_qqplots.png")

"""
LECTURE DU Q-Q PLOT :

Points sur la ligne diagonale -> Distribution normale
Points courbés vers le haut   -> Queue droite (skewness positif)
Points courbés vers le bas    -> Queue gauche (skewness négatif)
Points en S                   -> Queues épaisses (leptokurtique)
"""


# ----------------------------------------------------------------------------
# [ANALYSE] T-TEST : COMPARER DES MOYENNES
# ----------------------------------------------------------------------------

"""
TYPES DE T-TESTS

1. T-TEST À UN ÉCHANTILLON
   Compare la moyenne d'un groupe à une valeur de référence
   H0 : µ = µ0 (valeur théorique)

2. T-TEST INDÉPENDANT (Welch)
   Compare les moyennes de 2 groupes INDÉPENDANTS
   H0 : µ1 = µ2

3. T-TEST APPARIÉ
   Compare 2 mesures sur les MÊMES individus
   H0 : µ_avant = µ_après

QUAND UTILISER ?
-> 2 groupes à comparer
-> Variable dépendante continue
-> Distributions approximativement normales

ALTERNATIVE NON-PARAMÉTRIQUE :
-> Mann-Whitney U (pour t-test indépendant)
-> Wilcoxon signé (pour t-test apparié)
"""

print("\n" + "="*60)
print("T-TESTS")
print("="*60)

# ─── 1. T-Test à un échantillon ───
print("\n1. T-TEST À UN ÉCHANTILLON")
print("   Question : La moyenne générale est-elle différente de 12 ?")
print("   H0 : µ = 12")
print("   H1 : µ ≠ 12")

stat, p = ttest_1samp(df["moyenne"].dropna(), popmean=12)
print(f"   t-statistique : {stat:.4f}")
print(f"   p-value       : {p:.6f}")
print(f"   Conclusion    : {'Rejeter H0 (µ ≠ 12)' if p < 0.05 else 'Conserver H0'}")

# Taille d'effet (Cohen's d)
moy = df["moyenne"].dropna()
cohens_d = (moy.mean() - 12) / moy.std()
print(f"   Cohen's d     : {cohens_d:.3f} ({'Petit' if abs(cohens_d)<0.5 else 'Moyen' if abs(cohens_d)<0.8 else 'Grand'} effet)")

# ─── 2. T-Test indépendant ───
print("\n2. T-TEST INDÉPENDANT (2 groupes)")
print("   Question : Les boursiers ont-ils de meilleures moyennes ?")
print("   H0 : µ_boursiers = µ_non-boursiers")

boursiers = df[df["bourse"] == True]["moyenne"].dropna()
non_boursiers = df[df["bourse"] == False]["moyenne"].dropna()

# Test de Levene (égalité des variances)
stat_lev, p_lev = stats.levene(boursiers, non_boursiers)
equal_var = p_lev > 0.05
print(f"\n   Test de Levene (égalité des variances) :")
print(f"   stat={stat_lev:.4f}, p={p_lev:.4f} -> Variances {'égales' if equal_var else 'inégales'}")

# T-test (Welch si variances inégales)
stat, p = ttest_ind(boursiers, non_boursiers, equal_var=equal_var)
print(f"\n   Moyennes : Boursiers={boursiers.mean():.2f} vs Non-boursiers={non_boursiers.mean():.2f}")
print(f"   t-statistique : {stat:.4f}")
print(f"   p-value       : {p:.6f}")
print(f"   Conclusion    : {'Différence significative [OK]' if p < 0.05 else 'Pas de différence significative'}")

# Cohen's d
pooled_std = np.sqrt((boursiers.std()**2 + non_boursiers.std()**2) / 2)
cohens_d = (boursiers.mean() - non_boursiers.mean()) / pooled_std
print(f"   Cohen's d     : {abs(cohens_d):.3f}")

# ─── 3. Test non-paramétrique (Mann-Whitney) ───
print("\n3. TEST MANN-WHITNEY U (alternative non-paramétrique)")
print("   (À utiliser si non-normalité est avérée)")
stat_mw, p_mw = mannwhitneyu(boursiers, non_boursiers, alternative="two-sided")
print(f"   U-statistique : {stat_mw:.0f}")
print(f"   p-value       : {p_mw:.6f}")
print(f"   Conclusion    : {'Différence significative [OK]' if p_mw < 0.05 else 'Pas de différence'}")


# ----------------------------------------------------------------------------
# [GRAPHIQUE] ANOVA : COMPARER PLUS DE 2 GROUPES
# ----------------------------------------------------------------------------

"""
ANOVA (Analysis Of Variance)

QUAND UTILISER ?
-> Comparer 3 groupes ou plus
-> Variable dépendante continue
-> Groupes indépendants

H0 : µ1 = µ2 = µ3 = ... = µk (toutes les moyennes sont égales)
H1 : Au moins une moyenne est différente

IMPORTANT : L'ANOVA dit s'il y a UNE différence quelque part,
            mais pas ENTRE QUELS groupes !
-> Tests post-hoc nécessaires (Tukey HSD, Bonferroni)

CONDITIONS :
1. Normalité dans chaque groupe
2. Homogénéité des variances (Test de Levene)
3. Indépendance des observations

ALTERNATIVE NON-PARAMÉTRIQUE : Test de Kruskal-Wallis

TAILLE D'EFFET :
η² (Eta carré) = SS_between / SS_total
η² < 0.06  -> Petit effet
η² < 0.14  -> Effet moyen
η² ≥ 0.14  -> Grand effet
"""

print("\n" + "="*60)
print("ANOVA À UN FACTEUR : FILIÈRE -> MOYENNE")
print("="*60)

groupes = [df[df["filière"] == f]["moyenne"].dropna().values
           for f in df["filière"].unique()]
filières = list(df["filière"].unique())

# ANOVA
f_stat, p_anova = f_oneway(*groupes)
print(f"\nF-statistique : {f_stat:.4f}")
print(f"p-value       : {p_anova:.6f}")
print(f"Conclusion    : {'Différences significatives entre filières [OK]' if p_anova < 0.05 else 'Pas de différence'}")

# Taille d'effet (η²)
grand_moy = np.concatenate(groupes).mean()
ss_between = sum(len(g) * (g.mean() - grand_moy)**2 for g in groupes)
ss_total = sum(((x - grand_moy)**2) for g in groupes for x in g)
eta_carre = ss_between / ss_total
print(f"η² (taille d'effet) : {eta_carre:.4f} ({'Petit' if eta_carre < 0.06 else 'Moyen' if eta_carre < 0.14 else 'Grand'})")

# ─── Tests post-hoc (Tukey HSD) ───
print("\nTESTS POST-HOC (Tukey HSD) :")
print("Identifier QUELS groupes diffèrent :")

tukey = pairwise_tukeyhsd(
    df["moyenne"].dropna(),
    df.loc[df["moyenne"].notna(), "filière"],
    alpha=0.05
)
print(tukey)

# ─── ANOVA non-paramétrique (Kruskal-Wallis) ───
print("\nKRUSKAL-WALLIS (alternative non-paramétrique) :")
stat_kw, p_kw = kruskal(*groupes)
print(f"H-statistique : {stat_kw:.4f}")
print(f"p-value       : {p_kw:.6f}")


# ----------------------------------------------------------------------------
# χ² : TEST DU CHI-CARRÉ (Variables catégorielles)
# ----------------------------------------------------------------------------

"""
TEST DU CHI-CARRÉ D'INDÉPENDANCE

QUAND UTILISER ?
-> Tester si 2 variables catégorielles sont INDÉPENDANTES

H0 : Les 2 variables sont indépendantes (pas de relation)
H1 : Il existe une relation entre les variables

EXEMPLES :
"La filière influence-t-elle l'obtention d'une bourse ?"
"Le sexe influence-t-il la mention ?"
"La ville influence-t-elle la filière choisie ?"

TAILLE D'EFFET : V de Cramér
V = √(χ² / (n × min(r-1, c-1)))
V ≈ 0.1  -> Petit effet
V ≈ 0.3  -> Effet moyen
V ≈ 0.5  -> Grand effet

CONDITIONS :
- Fréquences attendues ≥ 5 dans chaque cellule
  (Si non : Test exact de Fisher pour 2×2)
"""

print("\n" + "="*60)
print("TEST CHI-CARRÉ : FILIÈRE × BOURSE")
print("="*60)
print("H0 : La filière et l'obtention de bourse sont indépendantes")

# Tableau de contingence
tab = pd.crosstab(df["filière"], df["bourse"])
print("\nTableau de contingence :")
print(tab)

# Chi-carré
chi2_stat, p_chi2, dof, expected = chi2_contingency(tab)
print(f"\nχ² statistique : {chi2_stat:.4f}")
print(f"Degrés liberté : {dof}")
print(f"p-value        : {p_chi2:.6f}")
print(f"Conclusion     : {'Relation significative [OK]' if p_chi2 < 0.05 else 'Pas de relation significative'}")

# V de Cramér
n = len(df)
min_dim = min(tab.shape[0] - 1, tab.shape[1] - 1)
cramer_v = np.sqrt(chi2_stat / (n * min_dim))
print(f"V de Cramér    : {cramer_v:.4f} ({'Petit' if cramer_v < 0.1 else 'Moyen' if cramer_v < 0.3 else 'Grand'} effet)")

print("\nFréquences attendues :")
print(pd.DataFrame(expected, index=tab.index, columns=tab.columns).round(1))
print("(Toutes ≥ 5 ? Chi-carré applicable !)")


# ----------------------------------------------------------------------------
# [MESURE] INTERVALLES DE CONFIANCE
# ----------------------------------------------------------------------------

"""
INTERVALLE DE CONFIANCE (IC)

Définition : Un IC à 95% signifie que si on répétait
             l'expérience 100 fois, 95 IC contiendraient
             le vrai paramètre.

IMPORTANT : ≠ "95% de chances que le paramètre
             soit dans cet intervalle"
             (Le paramètre est fixe, l'IC est aléatoire)

FORMULE (pour µ, σ inconnu) :
IC = x̄ ± t_{α/2, n-1} × (s/√n)

où t_{α/2, n-1} est le t de Student avec n-1 degrés de liberté

UTILISATION PRATIQUE :
-> Si IC ne contient pas la valeur de référence -> Test significatif
-> IC étroit -> Estimation précise (grand n)
-> IC large -> Estimation imprécise (petit n)
"""

print("\n" + "="*60)
print("INTERVALLES DE CONFIANCE")
print("="*60)

def ic_moyenne(data, confiance=0.95):
    """Calculer l'intervalle de confiance de la moyenne."""
    n = len(data)
    moy = data.mean()
    se = stats.sem(data)  # Erreur standard
    alpha = 1 - confiance
    # Valeur critique de t
    t_crit = stats.t.ppf(1 - alpha/2, df=n-1)
    marge = t_crit * se
    return moy, moy - marge, moy + marge, se

for col in ["note_math", "note_info", "note_science", "moyenne"]:
    data = df[col].dropna()
    moy, ic_bas, ic_haut, se = ic_moyenne(data)
    print(f"{col:20} : {moy:.2f} [IC 95%: {ic_bas:.2f} - {ic_haut:.2f}] (SE={se:.3f})")

# IC par groupe
print("\nIC 95% de la moyenne par filière :")
for filière in df["filière"].unique():
    data = df[df["filière"] == filière]["moyenne"].dropna()
    moy, ic_bas, ic_haut, _ = ic_moyenne(data)
    print(f"  {filière:15} : {moy:.2f} [{ic_bas:.2f}, {ic_haut:.2f}] (n={len(data)})")

# Visualisation IC
fig, ax = plt.subplots(figsize=(10, 6))

filières = df["filière"].unique()
moyennes = []
ic_bas_list = []
ic_haut_list = []

for filière in filières:
    data = df[df["filière"] == filière]["moyenne"].dropna()
    moy, ic_bas, ic_haut, _ = ic_moyenne(data)
    moyennes.append(moy)
    ic_bas_list.append(moy - ic_bas)
    ic_haut_list.append(ic_haut - moy)

ax.errorbar(
    filières, moyennes,
    yerr=[ic_bas_list, ic_haut_list],
    fmt="o", capsize=8, capthick=2,
    markersize=8, linewidth=2, color="steelblue"
)
ax.axhline(y=df["moyenne"].mean(), color="red", ls="--", label="Moyenne globale")
ax.set_ylabel("Moyenne générale")
ax.set_title("Intervalles de Confiance à 95% par Filière", fontweight="bold")
ax.legend()
ax.grid(True, alpha=0.3, axis="y")
plt.tight_layout()
plt.savefig("ch12_ic.png", dpi=150, bbox_inches="tight")
plt.show()


# ----------------------------------------------------------------------------
# [FORCE] PUISSANCE STATISTIQUE ET TAILLE D'EFFET
# ----------------------------------------------------------------------------

"""
PUISSANCE STATISTIQUE

Puissance = P(Rejeter H0 | H1 vraie) = 1 - β

Puissance ≥ 0.80 est généralement recommandée

FACTEURS QUI AUGMENTENT LA PUISSANCE :
1. Augmenter n (taille de l'échantillon)
2. Augmenter α (seuil, mais augmente erreur Type I)
3. Augmenter la taille d'effet (différence réelle)
4. Diminuer σ (variance)

UTILISATION PRATIQUE :
-> Calculer le n nécessaire avant l'étude
-> Évaluer si le test avait assez de puissance
"""

try:
    from statsmodels.stats.power import TTestIndPower, FTestAnovaPower

    # Calcul de la taille d'échantillon nécessaire
    print("\n" + "="*60)
    print("CALCUL DE LA TAILLE D'ÉCHANTILLON")
    print("="*60)

    analyse_puissance = TTestIndPower()

    # Pour différents effets et puissances cibles
    print("\nTaille d'échantillon par groupe nécessaire :")
    print(f"{'Effect size':>15} | {'Puissance 0.80':>15} | {'Puissance 0.90':>15}")
    print("-" * 50)
    for d in [0.2, 0.5, 0.8]:  # Petit, moyen, grand
        n_80 = analyse_puissance.solve_power(effect_size=d, alpha=0.05, power=0.80)
        n_90 = analyse_puissance.solve_power(effect_size=d, alpha=0.05, power=0.90)
        taille = "Petit" if d < 0.5 else "Moyen" if d < 0.8 else "Grand"
        print(f"{taille:>15} ({d}) | {n_80:>15.0f} | {n_90:>15.0f}")

except ImportError:
    print("statsmodels requis pour les calculs de puissance")


# ============================================================================
# [GUIDE] CHAPITRE 13 : CORRÉLATION ET RÉGRESSION
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Calculer et interpréter les corrélations (Pearson, Spearman)
[OK] Construire des modèles de régression linéaire simple et multiple
[OK] Évaluer la qualité d'un modèle (R², RMSE, résidus)
[OK] Régression avec variables catégorielles (ANCOVA)
[OK] Détecter et gérer la multicolinéarité
[OK] Régression polynomiale
"""


# ----------------------------------------------------------------------------
# [LIEN] MESURES DE CORRÉLATION
# ----------------------------------------------------------------------------

"""
TYPES DE CORRÉLATION

1. PEARSON (r) - Paramétrique
   -> Mesure la relation LINÉAIRE entre 2 variables continues
   -> Supposé : Distribution bivariée normale
   -> Sensible aux outliers

2. SPEARMAN (ρ) - Non-paramétrique
   -> Mesure la relation MONOTONE (pas forcément linéaire)
   -> Basé sur les rangs
   -> Robuste aux outliers

3. KENDALL (τ) - Non-paramétrique
   -> Similaire à Spearman mais plus robuste
   -> Préféré pour petits échantillons


RÈGLE GÉNÉRALE :
|r| < 0.2   -> Négligeable
|r| < 0.4   -> Faible
|r| < 0.6   -> Modérée
|r| < 0.8   -> Forte
|r| ≥ 0.8   -> Très forte

[ATTENTION] RAPPEL CRUCIAL : CORRÉLATION ≠ CAUSALITÉ
"""

print("\n" + "="*60)
print("ANALYSE DES CORRÉLATIONS")
print("="*60)

colonnes_num = ["note_math", "note_info", "note_science", "heures_étude", "moyenne"]

print("\nPearson vs Spearman vs Kendall (note_math <-> moyenne) :")
x = df["note_math"].dropna()
y = df.loc[df["note_math"].notna(), "moyenne"].dropna()
# Aligner indices
combined = df[["note_math", "moyenne"]].dropna()
x, y = combined["note_math"], combined["moyenne"]

r_p, p_p = pearsonr(x, y)
r_s, p_s = spearmanr(x, y)
r_k, p_k = kendalltau(x, y)

print(f"  Pearson  : r={r_p:.4f}, p={p_p:.6f}")
print(f"  Spearman : ρ={r_s:.4f}, p={p_s:.6f}")
print(f"  Kendall  : τ={r_k:.4f}, p={p_k:.6f}")

# Matrice de corrélation avec p-values
print("\nMatrice de corrélation avec p-values :")
print("(Format: r [p-value])\n")

corr_df = df[colonnes_num].dropna()
for col1 in colonnes_num:
    for col2 in colonnes_num:
        if col1 < col2:
            r, p = pearsonr(corr_df[col1], corr_df[col2])
            sig = "***" if p < 0.001 else "**" if p < 0.01 else "*" if p < 0.05 else ""
            print(f"  {col1:15} <-> {col2:15}: r={r:+.3f}{sig}")


# ----------------------------------------------------------------------------
# [HAUSSE] RÉGRESSION LINÉAIRE SIMPLE
# ----------------------------------------------------------------------------

"""
RÉGRESSION LINÉAIRE SIMPLE

Modèle : Y = β0 + β1×X + ε

β0 (Intercept) -> Valeur prédite quand X=0
β1 (Pente)     -> Variation de Y quand X augmente de 1 unité
ε (Résidu)     -> Erreur (non expliquée par le modèle)

ÉVALUATION DU MODÈLE

R² (Coefficient de détermination) :
-> Proportion de la variance de Y expliquée par X
-> 0 ≤ R² ≤ 1
-> R² = 0.80 -> Le modèle explique 80% de la variance

RMSE (Root Mean Square Error) :
-> MÊME UNITÉ que Y
-> Erreur quadratique moyenne

CONDITIONS D'APPLICATION (LINE) :
L -> Linéarité (relation linéaire X-Y)
I -> Indépendance des résidus
N -> Normalité des résidus
E -> Égalité des variances (homoscédasticité)
"""

print("\n" + "="*60)
print("RÉGRESSION LINÉAIRE SIMPLE : heures_étude -> moyenne")
print("="*60)

# Préparer données
data_reg = df[["heures_étude", "moyenne"]].dropna()
X = data_reg["heures_étude"].values
y = data_reg["moyenne"].values

# Avec scipy (simple)
pente, intercept, r_val, p_val, se = stats.linregress(X, y)
print(f"\nCoefficients :")
print(f"  Intercept (β0) : {intercept:.4f}")
print(f"  Pente (β1)     : {pente:.4f}")
print(f"\nInterprétation : Pour chaque heure d'étude supplémentaire,")
print(f"  la moyenne augmente de {pente:.4f} points")
print(f"\nQualité du modèle :")
print(f"  R²             : {r_val**2:.4f} ({r_val**2*100:.1f}% de variance expliquée)")
print(f"  p-value        : {p_val:.6f}")

# Avec statsmodels (complet)
print("\n── Rapport statsmodels complet ──")
X_sm = sm.add_constant(data_reg["heures_étude"])
model = sm.OLS(data_reg["moyenne"], X_sm).fit()
print(model.summary())

# Résidus
residus = model.resid
print(f"\nTest de normalité des résidus (Shapiro-Wilk) :")
stat, p = shapiro(residus[:200])  # Shapiro limité à 5000 obs
print(f"  W={stat:.4f}, p={p:.6f} -> Résidus {'normaux [OK]' if p > 0.05 else 'non-normaux [ATTENTION]'}")

# Visualisation complète
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
fig.suptitle("Régression Linéaire Simple - Diagnostic", fontsize=14, fontweight="bold")

# 1. Scatter + droite de régression
x_pred = np.linspace(X.min(), X.max(), 100)
y_pred = intercept + pente * x_pred
axes[0, 0].scatter(X, y, alpha=0.4, color="steelblue", s=30)
axes[0, 0].plot(x_pred, y_pred, "r-", lw=2.5, label=f"y = {intercept:.2f} + {pente:.3f}x")
axes[0, 0].set_xlabel("Heures d'étude")
axes[0, 0].set_ylabel("Moyenne")
axes[0, 0].set_title(f"Scatter + Régression (R²={r_val**2:.3f})")
axes[0, 0].legend()
axes[0, 0].grid(True, alpha=0.3)

# 2. Résidus vs valeurs prédites
y_hat = model.fittedvalues
axes[0, 1].scatter(y_hat, residus, alpha=0.4, color="coral", s=30)
axes[0, 1].axhline(0, color="red", ls="--", lw=2)
axes[0, 1].set_xlabel("Valeurs prédites")
axes[0, 1].set_ylabel("Résidus")
axes[0, 1].set_title("Résidus vs Prédictions\n(Homoscédasticité)")
axes[0, 1].grid(True, alpha=0.3)

# 3. Q-Q plot des résidus
stats.probplot(residus, plot=axes[1, 0])
axes[1, 0].set_title("Q-Q Plot des Résidus\n(Normalité)")

# 4. Histogramme des résidus
axes[1, 1].hist(residus, bins=30, density=True, color="seagreen", alpha=0.7, edgecolor="white")
x_norm = np.linspace(residus.min(), residus.max(), 100)
axes[1, 1].plot(x_norm, stats.norm.pdf(x_norm, residus.mean(), residus.std()),
                "r-", lw=2, label="Distribution normale")
axes[1, 1].set_xlabel("Résidus")
axes[1, 1].set_title("Distribution des Résidus")
axes[1, 1].legend()

plt.tight_layout()
plt.savefig("ch13_regression_simple.png", dpi=150, bbox_inches="tight")
plt.show()


# ----------------------------------------------------------------------------
# [GRAPHIQUE] RÉGRESSION LINÉAIRE MULTIPLE
# ----------------------------------------------------------------------------

"""
RÉGRESSION LINÉAIRE MULTIPLE

Modèle : Y = β0 + β1×X1 + β2×X2 + ... + βp×Xp + ε

NOUVEAUTÉS vs Régression Simple :

R² AJUSTÉ :
-> Pénalise l'ajout de variables inutiles
-> Toujours utiliser le R² ajusté pour comparer modèles
-> R²_adj = 1 - (1-R²) × (n-1)/(n-p-1)

MULTICOLINÉARITÉ :
-> Problème quand des prédicteurs sont fortement corrélés entre eux
-> Les coefficients deviennent instables
-> Détecter avec VIF (Variance Inflation Factor)
   VIF < 5  -> Acceptable
   VIF < 10 -> Modéré (attention)
   VIF ≥ 10 -> Problématique

VARIABLES CATÉGORIELLES :
-> Convertir en variables muettes (dummy variables)
-> k catégories -> k-1 variables muettes
-> Une catégorie devient la référence
"""

print("\n" + "="*60)
print("RÉGRESSION LINÉAIRE MULTIPLE")
print("="*60)

# Préparer données avec variables catégorielles
data_mult = df[["note_math", "note_info", "note_science",
                "heures_étude", "filière", "bourse", "moyenne"]].dropna()

# Option 1 : statsmodels formule (type R)
print("\nModèle avec formule (R-style) :")
model_formula = smf.ols(
    "moyenne ~ note_math + note_info + note_science + heures_étude + C(filière) + bourse",
    data=data_mult
).fit()
print(model_formula.summary())

# Option 2 : sklearn avec encodage manuel
from sklearn.linear_model import LinearRegression
from sklearn.preprocessing import OneHotEncoder
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline

features = ["note_math", "note_info", "note_science", "heures_étude", "filière"]
X_ml = data_mult[features]
y_ml = data_mult["moyenne"]

pipeline_reg = Pipeline([
    ("preprocessor", ColumnTransformer([
        ("num", "passthrough", ["note_math", "note_info", "note_science", "heures_étude"]),
        ("cat", OneHotEncoder(drop="first"), ["filière"])
    ])),
    ("model", LinearRegression())
])
pipeline_reg.fit(X_ml, y_ml)
r2 = pipeline_reg.score(X_ml, y_ml)
print(f"\nR² du modèle multiple : {r2:.4f}")

# VIF (Variance Inflation Factor)
print("\nVIF (Variance Inflation Factor) :")
from statsmodels.stats.outliers_influence import variance_inflation_factor

colonnes_vif = ["note_math", "note_info", "note_science", "heures_étude"]
X_vif = sm.add_constant(data_mult[colonnes_vif])
vif_data = pd.DataFrame()
vif_data["Variable"] = X_vif.columns
vif_data["VIF"] = [variance_inflation_factor(X_vif.values, i)
                   for i in range(X_vif.shape[1])]
print(vif_data)
print("\nVIF < 5 : Pas de multicolinéarité [OK]")
print("VIF > 10 : Multicolinéarité problématique [ATTENTION]")


# ----------------------------------------------------------------------------
# [SYNC] RÉGRESSION POLYNOMIALE
# ----------------------------------------------------------------------------

"""
RÉGRESSION POLYNOMIALE

Quand la relation n'est pas linéaire !

Modèle : Y = β0 + β1×X + β2×X² + β3×X³ + ε

ATTENTION :
-> Plus le degré est élevé, plus le modèle "colle" aux données
-> Risque de SURAPPRENTISSAGE (overfitting)
-> Comparer avec R² ajusté, pas R²

EXEMPLE PRATIQUE :
Heures d'étude : relation en cloche possible
(Trop peu = mauvaises notes, trop = burn-out = mauvaises notes)
"""

print("\n" + "="*60)
print("RÉGRESSION POLYNOMIALE")
print("="*60)

from sklearn.preprocessing import PolynomialFeatures

data_poly = df[["heures_étude", "moyenne"]].dropna()
X_poly = data_poly["heures_étude"].values.reshape(-1, 1)
y_poly = data_poly["moyenne"].values

fig, axes = plt.subplots(1, 3, figsize=(16, 5))
fig.suptitle("Régression Polynomiale - Comparaison des degrés", fontweight="bold")

x_range = np.linspace(X_poly.min(), X_poly.max(), 300).reshape(-1, 1)

for i, degré in enumerate([1, 2, 4]):
    poly = PolynomialFeatures(degree=degré)
    X_p = poly.fit_transform(X_poly)
    X_r = poly.transform(x_range)

    reg = LinearRegression().fit(X_p, y_poly)
    y_pred_range = reg.predict(X_r)
    r2 = reg.score(X_p, y_poly)

    axes[i].scatter(X_poly, y_poly, alpha=0.3, color="steelblue", s=20)
    axes[i].plot(x_range, y_pred_range, "r-", lw=2.5)
    axes[i].set_title(f"Degré {degré}\nR² = {r2:.4f}")
    axes[i].set_xlabel("Heures d'étude")
    axes[i].set_ylabel("Moyenne")
    axes[i].grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig("ch13_poly_regression.png", dpi=150, bbox_inches="tight")
plt.show()


# ============================================================================
# [GUIDE] CHAPITRE 14 : ANALYSE TEMPORELLE (TIME SERIES)
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Créer et indexer des séries temporelles avec Pandas
[OK] Décomposer une série (tendance, saisonnalité, résidu)
[OK] Calculer les statistiques glissantes (rolling)
[OK] Détecter la stationnarité
[OK] Faire des prévisions simples (ARIMA, Prophet)
[OK] Visualiser des séries temporelles
"""


# ----------------------------------------------------------------------------
# [HEURE] STRUCTURE D'UNE SÉRIE TEMPORELLE
# ----------------------------------------------------------------------------

"""
COMPOSANTES D'UNE SÉRIE TEMPORELLE

Y(t) = Tendance(t) + Saisonnalité(t) + Résidu(t)   [Additive]
Y(t) = Tendance(t) × Saisonnalité(t) × Résidu(t)   [Multiplicative]

TENDANCE : Direction générale sur le long terme
           -> Ventes en croissance, population croissante

SAISONNALITÉ : Fluctuations régulières et prévisibles
               -> Ventes de glace en été
               -> Impôts en avril
               -> Trafic plus élevé en semaine

CYCLE : Oscillations moins régulières que la saisonnalité
        -> Cycles économiques (3-10 ans)

RÉSIDU (Bruit) : Variation inexpliquée
                 -> Événements aléatoires imprévus
"""

# Créer une série temporelle réaliste
np.random.seed(42)
dates = pd.date_range(start="2020-01-01", end="2023-12-31", freq="D")
n_ts = len(dates)

# Composantes
tendance = np.linspace(100, 200, n_ts)  # Croissance linéaire
saisonnalité = 20 * np.sin(2 * np.pi * np.arange(n_ts) / 365.25)  # Annuelle
saisonnalité_hebdo = 5 * np.sin(2 * np.pi * np.arange(n_ts) / 7)  # Hebdomadaire
bruit = np.random.normal(0, 5, n_ts)

ts_values = tendance + saisonnalité + saisonnalité_hebdo + bruit

ts = pd.Series(ts_values, index=dates, name="ventes")

print("\n" + "="*60)
print("SÉRIE TEMPORELLE - STATISTIQUES GLOBALES")
print("="*60)
print(f"Période      : {ts.index[0].date()} -> {ts.index[-1].date()}")
print(f"Fréquence    : Journalière")
print(f"N obs        : {len(ts)}")
print(f"Moyenne      : {ts.mean():.2f}")
print(f"Écart-type   : {ts.std():.2f}")
print(f"Min / Max    : {ts.min():.2f} / {ts.max():.2f}")


# ----------------------------------------------------------------------------
# [SYNC] RÉÉCHANTILLONNAGE ET AGRÉGATION TEMPORELLE
# ----------------------------------------------------------------------------

"""
RÉÉCHANTILLONNAGE (Resampling)

DOWNSAMPLING : Passer d'une fréquence élevée à une basse
               Journalier -> Hebdomadaire -> Mensuel -> Annuel

UPSAMPLING : Passer d'une fréquence basse à une haute
             Mensuel -> Journalier (nécessite interpolation)
"""

print("\nRÉÉCHANTILLONNAGE :")

# Hebdomadaire
ts_hebdo = ts.resample("W").mean()
print(f"Hebdomadaire  : {len(ts_hebdo)} semaines")

# Mensuel
ts_mensuel = ts.resample("ME").agg(["mean", "min", "max", "std"])
print(f"Mensuel       : {len(ts_mensuel)} mois")

# Annuel
ts_annuel = ts.resample("YE").mean()
print(f"Annuel        : {len(ts_annuel)} années")
print(ts_annuel)

# Extraction de périodes
ts_2022 = ts["2022"]
ts_q1_2022 = ts["2022-01":"2022-03"]
print(f"\n2022          : {len(ts_2022)} jours")
print(f"Q1 2022       : {len(ts_q1_2022)} jours")


# ----------------------------------------------------------------------------
# [GRAPHIQUE] STATISTIQUES GLISSANTES (ROLLING STATISTICS)
# ----------------------------------------------------------------------------

"""
STATISTIQUES GLISSANTES (ROLLING)

POURQUOI :
-> Lisser les fluctuations à court terme
-> Visualiser la tendance
-> Identifier des anomalies

TYPES :
- Rolling mean (Moyenne mobile) -> Lissage
- Rolling std (Écart-type mobile) -> Volatilité
- Rolling min/max -> Extrêmes sur fenêtre

FENÊTRE GLISSANTE :
-> Fenêtre de 7 jours = Moyenne des 7 derniers jours
-> Fenêtre de 30 jours = Tendance mensuelle

ROLLING vs EXPANDING :
Rolling  : Fenêtre de taille fixe (W derniers jours)
Expanding: Depuis le début jusqu'au point courant
"""

# Calcul des statistiques glissantes
window_7 = ts.rolling(window=7, min_periods=1)
window_30 = ts.rolling(window=30, min_periods=1)

ts_moy_7 = window_7.mean()
ts_moy_30 = window_30.mean()
ts_std_30 = window_30.std()
ts_expanding = ts.expanding().mean()

# Visualisation 2022 seulement
ts_plot = ts["2022"]
ts_moy7_plot = ts_moy_7["2022"]
ts_moy30_plot = ts_moy_30["2022"]
ts_std30_plot = ts_std_30["2022"]

fig, axes = plt.subplots(2, 1, figsize=(16, 10))
fig.suptitle("Analyse de Série Temporelle - Statistiques Glissantes", fontsize=14, fontweight="bold")

# Graphique 1 : Données + moyennes mobiles
axes[0].plot(ts_plot.index, ts_plot.values, alpha=0.5, color="steelblue",
             linewidth=0.8, label="Données brutes")
axes[0].plot(ts_moy7_plot.index, ts_moy7_plot.values, color="orange",
             linewidth=2, label="MM 7 jours")
axes[0].plot(ts_moy30_plot.index, ts_moy30_plot.values, color="red",
             linewidth=2.5, label="MM 30 jours")
axes[0].set_title("Données brutes + Moyennes mobiles")
axes[0].legend()
axes[0].set_ylabel("Valeur")
axes[0].grid(True, alpha=0.3)

# Graphique 2 : Bandes de Bollinger
moy30 = ts_moy30_plot
std30 = ts_std30_plot
axes[1].plot(moy30.index, moy30.values, color="blue", lw=2, label="Moyenne 30j")
axes[1].fill_between(moy30.index,
                     moy30 - 2 * std30,
                     moy30 + 2 * std30,
                     alpha=0.2, color="blue", label="Bandes ±2σ")
axes[1].plot(ts_plot.index, ts_plot.values, alpha=0.4, color="gray", lw=0.8)
axes[1].set_title("Bandes de Bollinger (MM30 ± 2σ)")
axes[1].legend()
axes[1].set_ylabel("Valeur")
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig("ch14_rolling.png", dpi=150, bbox_inches="tight")
plt.show()


# ----------------------------------------------------------------------------
# [RECHERCHE] DÉCOMPOSITION DE LA SÉRIE TEMPORELLE
# ----------------------------------------------------------------------------

"""
DÉCOMPOSITION CLASSIQUE (STL)

Sépare la série en ses composantes :
1. Tendance
2. Saisonnalité
3. Résidu (bruit)

TYPES :
Additif      : Y = T + S + R (amplitude constante)
Multiplicatif: Y = T × S × R (amplitude croissante avec la tendance)

CHOISIR LE TYPE :
Si la saisonnalité AUGMENTE avec le niveau -> Multiplicatif
Si la saisonnalité est CONSTANTE -> Additif
"""

from statsmodels.tsa.seasonal import seasonal_decompose, STL

# Série mensuelle pour décomposition plus claire
ts_mensuel_decomp = ts.resample("ME").mean()

# Décomposition classique (additive)
decomp = seasonal_decompose(ts_mensuel_decomp, model="additive", period=12)

fig, axes = plt.subplots(4, 1, figsize=(16, 12))
fig.suptitle("Décomposition de la Série Temporelle", fontsize=14, fontweight="bold")

axes[0].plot(decomp.observed, color="steelblue")
axes[0].set_title("Série Originale")
axes[0].set_ylabel("Valeur")

axes[1].plot(decomp.trend, color="red", lw=2)
axes[1].set_title("Tendance")
axes[1].set_ylabel("Valeur")

axes[2].plot(decomp.seasonal, color="green", lw=1.5)
axes[2].set_title("Saisonnalité")
axes[2].set_ylabel("Valeur")
axes[2].axhline(0, color="black", ls="--", alpha=0.5)

axes[3].plot(decomp.resid, color="gray", alpha=0.7)
axes[3].set_title("Résidus (Bruit)")
axes[3].set_ylabel("Valeur")
axes[3].axhline(0, color="black", ls="--", alpha=0.5)

for ax in axes:
    ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig("ch14_decomposition.png", dpi=150, bbox_inches="tight")
plt.show()


# ----------------------------------------------------------------------------
# [BAISSE] TEST DE STATIONNARITÉ
# ----------------------------------------------------------------------------

"""
STATIONNARITÉ

Une série est STATIONNAIRE si :
1. Moyenne constante dans le temps
2. Variance constante dans le temps
3. Pas de tendance ni saisonnalité

POURQUOI IMPORTANT ?
-> La plupart des modèles de prévision (ARIMA) requièrent la stationnarité

TEST AUGMENTED DICKEY-FULLER (ADF) :
H0 : La série est NON stationnaire (racine unitaire)
H1 : La série est stationnaire

Si p < 0.05 -> Rejeter H0 -> Série STATIONNAIRE [OK]
Si p ≥ 0.05 -> Conserver H0 -> Série NON stationnaire [ATTENTION]

RENDRE STATIONNAIRE :
-> Différenciation : d(Yt) = Yt - Yt-1
-> Transformation logarithmique
-> Détrending
"""

from statsmodels.tsa.stattools import adfuller, kpss

def test_stationnarité(series, nom="Série"):
    """Effectue et interprète les tests de stationnarité ADF et KPSS."""
    print(f"\nTest de stationnarité : {nom}")
    print("-" * 50)

    # Test ADF
    adf_result = adfuller(series.dropna(), autolag="AIC")
    print(f"ADF Test :")
    print(f"  Statistique : {adf_result[0]:.4f}")
    print(f"  p-value     : {adf_result[1]:.4f}")
    print(f"  Valeurs critiques : {adf_result[4]}")
    adf_stationnaire = adf_result[1] < 0.05
    print(f"  -> {'STATIONNAIRE [OK]' if adf_stationnaire else 'NON STATIONNAIRE [ATTENTION]'}")

    return adf_stationnaire

# Série originale
s_orig = test_stationnarité(ts_mensuel_decomp, "Série originale (mensuelle)")

# Après différenciation
ts_diff1 = ts_mensuel_decomp.diff()
s_diff1 = test_stationnarité(ts_diff1.dropna(), "Après 1ère différenciation")

# Après double différenciation (si nécessaire)
if not s_diff1:
    ts_diff2 = ts_diff1.diff()
    test_stationnarité(ts_diff2.dropna(), "Après 2ème différenciation")


# ----------------------------------------------------------------------------
# [CRYSTAL_BALL] PRÉVISIONS SIMPLES
# ----------------------------------------------------------------------------

"""
MODÈLES DE PRÉVISION

1. MOYENNE SIMPLE -> Prédire la valeur moyenne
2. LISSAGE EXPONENTIEL (ETS) -> Pondère plus les observations récentes
3. ARIMA -> Auto-Regressive Integrated Moving Average
4. SARIMA -> ARIMA avec saisonnalité
5. PROPHET -> Développé par Meta, facile à utiliser

CHOIX DU MODÈLE :
Série simple sans saisonnalité    -> ARIMA(p,d,q)
Série saisonnière                 -> SARIMA(p,d,q)(P,D,Q,m)
Série complexe avec jours fériés  -> Prophet
"""

print("\n" + "="*60)
print("PRÉVISIONS AVEC ARIMA")
print("="*60)

from statsmodels.tsa.arima.model import ARIMA
from statsmodels.tsa.statespace.sarimax import SARIMAX

# Utiliser la série mensuelle
ts_train = ts_mensuel_decomp["2020":"2022"]
ts_test = ts_mensuel_decomp["2023"]

print(f"Train : {len(ts_train)} mois (2020-2022)")
print(f"Test  : {len(ts_test)} mois (2023)")

# SARIMA (p,d,q)(P,D,Q,m)
try:
    model_sarima = SARIMAX(
        ts_train,
        order=(1, 1, 1),          # p=1, d=1, q=1
        seasonal_order=(1, 1, 1, 12)  # Saisonnalité annuelle
    ).fit(disp=False)

    print(f"\nSARIMA(1,1,1)(1,1,1,12) - AIC : {model_sarima.aic:.2f}")

    # Prévisions
    n_pred = len(ts_test) + 12  # Prévoir jusqu'en 2024
    forecast = model_sarima.get_forecast(steps=n_pred)
    pred_mean = forecast.predicted_mean
    pred_ci = forecast.conf_int()

    # Évaluation sur test
    pred_test = pred_mean[:len(ts_test)]
    mse = ((ts_test.values - pred_test.values[:len(ts_test)])**2).mean()
    rmse = np.sqrt(mse)
    mae = np.abs(ts_test.values - pred_test.values[:len(ts_test)]).mean()

    print(f"\nMétriques sur données test :")
    print(f"  RMSE : {rmse:.4f}")
    print(f"  MAE  : {mae:.4f}")

    # Visualisation
    fig, ax = plt.subplots(figsize=(16, 6))

    ax.plot(ts_train.index, ts_train.values, color="steelblue", lw=1.5, label="Train")
    ax.plot(ts_test.index, ts_test.values, color="seagreen", lw=1.5, label="Test (réel)")
    ax.plot(pred_mean.index, pred_mean.values, color="red", lw=2, ls="--", label="Prévisions")
    ax.fill_between(pred_ci.index,
                    pred_ci.iloc[:, 0],
                    pred_ci.iloc[:, 1],
                    alpha=0.2, color="red", label="IC 95%")
    ax.axvline(x=ts_train.index[-1], color="gray", ls=":", lw=2)
    ax.set_title(f"Prévisions SARIMA(1,1,1)(1,1,1,12) - RMSE={rmse:.2f}", fontweight="bold")
    ax.legend()
    ax.set_ylabel("Valeur")
    ax.grid(True, alpha=0.3)

    plt.tight_layout()
    plt.savefig("ch14_previsions.png", dpi=150, bbox_inches="tight")
    plt.show()

except Exception as e:
    print(f"Erreur SARIMA : {e}")
    print("Essayez : pip install statsmodels")


# ============================================================================
# [COURS] EXERCICE PRATIQUE 4 : ANALYSE STATISTIQUE COMPLÈTE
# ============================================================================

"""
OBJECTIF : Réaliser une analyse statistique complète
           avec tests d'hypothèses et visualisations

CONTEXTE : Dataset d'une étude médicale fictive
           Comparer l'efficacité de 3 traitements
"""

print("\n" + "="*70)
print("[COURS] EXERCICE : ÉTUDE CLINIQUE FICTIVE")
print("="*70)

np.random.seed(42)
n_patients = 150

# Simuler l'étude
traitement = np.repeat(["Traitement A", "Traitement B", "Placebo"], n_patients // 3)
age = np.random.randint(30, 70, n_patients)

# Effets différents par traitement
effets = {
    "Traitement A": (15, 5),   # Meilleure efficacité
    "Traitement B": (10, 6),   # Efficacité modérée
    "Placebo": (3, 4)          # Effet placebo faible
}

reduction_symptomes = np.array([
    np.clip(np.random.normal(effets[t][0], effets[t][1]), -5, 30)
    for t in traitement
]).round(1)

df_etude = pd.DataFrame({
    "traitement": traitement,
    "age": age,
    "reduction_symptomes": reduction_symptomes,
    "effets_secondaires": np.random.choice([True, False], n_patients,
                                            p=[0.2, 0.8])
})

print("\nDataset de l'étude :")
print(df_etude.head(10))
print(f"\nShape : {df_etude.shape}")
print(f"\nGroupes :")
print(df_etude["traitement"].value_counts())

# ── ÉTAPE 1 : Statistiques descriptives ──
print("\n── 1. STATISTIQUES DESCRIPTIVES ──")
desc = df_etude.groupby("traitement")["reduction_symptomes"].agg(
    ["mean", "median", "std", "min", "max", "count"]
).round(2)
print(desc)

# ── ÉTAPE 2 : Test de normalité ──
print("\n── 2. TESTS DE NORMALITÉ ──")
for trait in df_etude["traitement"].unique():
    data = df_etude[df_etude["traitement"] == trait]["reduction_symptomes"]
    stat, p = shapiro(data)
    print(f"{trait}: W={stat:.4f}, p={p:.4f} -> {'Normale [OK]' if p > 0.05 else 'Non-normale [ATTENTION]'}")

# ── ÉTAPE 3 : Test principal (ANOVA) ──
print("\n── 3. ANOVA À UN FACTEUR ──")
groupes_traitement = [
    df_etude[df_etude["traitement"] == t]["reduction_symptomes"].values
    for t in df_etude["traitement"].unique()
]
f_stat, p_anova = f_oneway(*groupes_traitement)
print(f"F = {f_stat:.4f}, p = {p_anova:.6f}")
print(f"Conclusion : {'Différences significatives [OK]' if p_anova < 0.05 else 'Pas de différence'}")

# ── ÉTAPE 4 : Tests post-hoc ──
print("\n── 4. TESTS POST-HOC (TUKEY HSD) ──")
tukey = pairwise_tukeyhsd(
    df_etude["reduction_symptomes"],
    df_etude["traitement"],
    alpha=0.05
)
print(tukey)

# ── ÉTAPE 5 : Chi-carré (effets secondaires) ──
print("\n── 5. CHI-CARRÉ : TRAITEMENT × EFFETS SECONDAIRES ──")
tab = pd.crosstab(df_etude["traitement"], df_etude["effets_secondaires"])
chi2_stat, p_chi2, dof, expected = chi2_contingency(tab)
print(f"χ² = {chi2_stat:.4f}, p = {p_chi2:.4f}")
print(f"Conclusion : {'Différence significative' if p_chi2 < 0.05 else 'Pas de différence'}")

# ── ÉTAPE 6 : Régression (contrôler l'âge) ──
print("\n── 6. RÉGRESSION (CONTRÔLER L'ÂGE) ──")
model_cov = smf.ols(
    "reduction_symptomes ~ C(traitement, Treatment(reference='Placebo')) + age",
    data=df_etude
).fit()
print(model_cov.summary().tables[1])  # Seulement le tableau des coefficients

# ── ÉTAPE 7 : Visualisation complète ──
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
fig.suptitle("Analyse Complète - Étude Clinique Fictive",
             fontsize=14, fontweight="bold")

# 1. Box plot
couleurs_t = {"Traitement A": "#2ECC71", "Traitement B": "#3498DB", "Placebo": "#E74C3C"}
for i, (trait, couleur) in enumerate(couleurs_t.items()):
    data = df_etude[df_etude["traitement"] == trait]["reduction_symptomes"]
    axes[0, 0].boxplot(data, positions=[i], patch_artist=True,
                       boxprops=dict(facecolor=couleur, alpha=0.7))
axes[0, 0].set_xticks(range(3))
axes[0, 0].set_xticklabels(couleurs_t.keys())
axes[0, 0].set_title("Distribution par traitement")
axes[0, 0].set_ylabel("Réduction des symptômes")
axes[0, 0].grid(True, alpha=0.3, axis="y")

# 2. Moyennes + IC
moyennes = [g.mean() for g in groupes_traitement]
ic_se = [stats.sem(g) * 1.96 for g in groupes_traitement]
axes[0, 1].bar(
    list(couleurs_t.keys()), moyennes,
    color=list(couleurs_t.values()),
    alpha=0.8, yerr=ic_se, capsize=6, edgecolor="black"
)
axes[0, 1].set_title("Moyennes ± IC 95%")
axes[0, 1].set_ylabel("Réduction moyenne")
for i, (m, e) in enumerate(zip(moyennes, ic_se)):
    axes[0, 1].text(i, m + e + 0.3, f"{m:.1f}", ha="center", fontweight="bold")

# 3. Violin plot
sns.violinplot(
    data=df_etude, x="traitement", y="reduction_symptomes",
    palette=couleurs_t, inner="box", ax=axes[1, 0]
)
axes[1, 0].set_title("Violin Plot")
axes[1, 0].set_xlabel("Traitement")
axes[1, 0].set_ylabel("Réduction")
axes[1, 0].tick_params(axis="x", rotation=15)

# 4. Scatter Age vs Réduction par traitement
for trait, couleur in couleurs_t.items():
    mask = df_etude["traitement"] == trait
    axes[1, 1].scatter(
        df_etude[mask]["age"],
        df_etude[mask]["reduction_symptomes"],
        c=couleur, label=trait, alpha=0.5, s=30
    )
axes[1, 1].set_xlabel("Âge")
axes[1, 1].set_ylabel("Réduction des symptômes")
axes[1, 1].set_title("Âge vs Réduction par traitement")
axes[1, 1].legend()
axes[1, 1].grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig("ch14_etude_clinique.png", dpi=150, bbox_inches="tight")
plt.show()

print("\n[OK] Analyse statistique complète terminée !")
print("Graphiques sauvegardés.")

# ============================================================================
# [DOCS] RÉCAPITULATIF PARTIE 4
# ============================================================================

"""
[BRAVO] FÉLICITATIONS ! PARTIE 4 TERMINÉE !

VOUS MAÎTRISEZ MAINTENANT :

Chapitre 11 : Statistiques Descriptives
[OK] Tendance centrale (moyenne, médiane, mode, pondérée)
[OK] Dispersion (variance, écart-type, IQR, MAD, CV)
[OK] Percentiles et five-number summary
[OK] Asymétrie et aplatissement (skewness, kurtosis)
[OK] Tableaux croisés (crosstab)
[OK] Rapport automatisé

Chapitre 12 : Statistiques Inférentielles
[OK] Logique des tests (H0, H1, p-value, α)
[OK] Erreurs Type I et Type II
[OK] Test de normalité (Shapiro-Wilk, D'Agostino)
[OK] T-tests (1 échantillon, indépendant, apparié)
[OK] ANOVA + Tests post-hoc (Tukey)
[OK] Chi-carré d'indépendance
[OK] Tests non-paramétriques (Mann-Whitney, Kruskal-Wallis)
[OK] Intervalles de confiance
[OK] Puissance statistique et taille d'effet

Chapitre 13 : Corrélation et Régression
[OK] Corrélations (Pearson, Spearman, Kendall)
[OK] Régression linéaire simple (scipy + statsmodels)
[OK] Diagnostic des résidus
[OK] Régression multiple avec variables catégorielles
[OK] VIF (multicolinéarité)
[OK] Régression polynomiale

Chapitre 14 : Séries Temporelles
[OK] Indexation temporelle et rééchantillonnage
[OK] Statistiques glissantes (rolling)
[OK] Décomposition (tendance, saisonnalité, résidu)
[OK] Test de stationnarité (ADF)
[OK] Prévisions avec SARIMA


[CLE] RÈGLES D'OR DES TESTS STATISTIQUES

1. Toujours vérifier les conditions AVANT le test
2. La p-value seule ne suffit pas -> Calculer la taille d'effet
3. p < 0.05 ≠ Effet important en pratique
4. Avec un grand n, presque tout est "significatif"
5. Corrélation ≠ Causalité (TOUJOURS)
6. Signaler l'incertitude : Intervalles de Confiance
7. Tests multiples -> Correction (Bonferroni ou FDR)
8. Préférer les tests non-paramétriques si doute sur la normalité


-> PROCHAINE ÉTAPE : PARTIE 5 - MACHINE LEARNING

Vous allez apprendre :
- Scikit-learn de A à Z
- Classification, Régression, Clustering
- Évaluation et optimisation des modèles
- Prêt pour la Partie 5 ? [BOT]
"""

# ============================================================================
# [LIVRE] ANALYSE DE DONNÉES AVEC PYTHON
# PARTIE 5 : MACHINE LEARNING
# ============================================================================
#
# [OBJECTIF] CETTE PARTIE COUVRE :
# - Chapitre 15 : Introduction au Machine Learning
# - Chapitre 16 : Scikit-learn - Fondamentaux
# - Chapitre 17 : Modèles de Classification
# - Chapitre 18 : Modèles de Régression
# - Chapitre 19 : Clustering et Réduction de Dimensions
# - Chapitre 20 : Évaluation et Optimisation des Modèles
#
# [TEMPS] TEMPS : ~12-15 heures
# [DOCS] PRÉREQUIS : Parties 1-4 complétées
# ============================================================================

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.cm as cm
import seaborn as sns
from sklearn.model_selection import (
    train_test_split, cross_val_score, StratifiedKFold,
    GridSearchCV, RandomizedSearchCV, learning_curve
)
from sklearn.preprocessing import (
    StandardScaler, MinMaxScaler, LabelEncoder, OneHotEncoder
)
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.metrics import (
    accuracy_score, precision_score, recall_score, f1_score,
    classification_report, confusion_matrix, roc_auc_score, roc_curve,
    mean_squared_error, mean_absolute_error, r2_score,
    silhouette_score, davies_bouldin_score
)
import warnings
warnings.filterwarnings("ignore")

sns.set_theme(style="whitegrid", font_scale=1.0)
plt.rcParams["figure.dpi"] = 100


# ============================================================================
# [GUIDE] CHAPITRE 15 : INTRODUCTION AU MACHINE LEARNING
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Comprendre les types de ML (supervisé, non-supervisé, par renforcement)
[OK] Maîtriser le vocabulaire essentiel (features, labels, overfitting...)
[OK] Comprendre le split train/test et la validation croisée
[OK] Éviter les pièges courants (data leakage, overfitting)
[OK] Choisir le bon algorithme selon le problème
"""


# ----------------------------------------------------------------------------
# [BOT] QU'EST-CE QUE LE MACHINE LEARNING ?
# ----------------------------------------------------------------------------

"""
DÉFINITION

Le Machine Learning (Apprentissage Automatique) est la capacité
d'un programme à apprendre à partir des données sans être
explicitement programmé.

ANALOGIE AVEC L'HUMAIN :

Programmation traditionnelle :
  Règles + Données -> Résultats
  Exemple : "Si email contient 'gagnez', c'est du spam"

Machine Learning :
  Données + Résultats -> Règles (que le modèle découvre lui-même)
  Exemple : Le modèle apprend seul à reconnaître le spam


3 TYPES PRINCIPAUX DE ML

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

1. SUPERVISÉ -> On connaît les réponses (labels)

   CLASSIFICATION : Prédire une catégorie
     - Email -> Spam ou Non-spam
     - Image -> Chat, Chien, Oiseau
     - Patient -> Malade ou Sain

   RÉGRESSION : Prédire une valeur continue
     - Prix d'une maison
     - Température demain
     - Score d'une étudiante

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

2. NON SUPERVISÉ -> Pas de labels, trouver des structures

   CLUSTERING : Regrouper des données similaires
     - Segmentation de clients
     - Détection d'anomalies

   RÉDUCTION DE DIMENSIONS : Compresser les données
     - PCA, UMAP, t-SNE
     - Visualisation de données haute dimension

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

3. PAR RENFORCEMENT -> Apprendre par essai-erreur

   Un agent interagit avec un environnement
   et reçoit des récompenses/punitions
   - Jeux (AlphaGo, ChatGPT fine-tuning)
   - Robotique
   - Trading algorithmique

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


VOCABULAIRE ESSENTIEL

Features (X) -> Variables d'entrée = prédicteurs
  Ex: taille, poids, âge, note_math

Target/Label (y) -> Variable à prédire
  Ex: maladie, prix, catégorie

Modèle -> Fonction mathématique apprise
         y_prédit = f(X)

Entraînement (fitting) -> Ajuster le modèle aux données
                          model.fit(X_train, y_train)

Prédiction -> Appliquer le modèle à de nouvelles données
              y_pred = model.predict(X_test)

Hyperparamètres -> Paramètres de configuration du modèle
                  (pas appris, mais choisis par le développeur)
                  Ex: profondeur max d'un arbre, k dans KNN


OVERFITTING vs UNDERFITTING

UNDERFITTING (Sous-apprentissage) :
-> Modèle trop simple
-> N'apprend pas suffisamment les données
-> Mauvaises performances train ET test
-> Solution : Modèle plus complexe, plus de features

OVERFITTING (Sur-apprentissage) :
-> Modèle trop complexe
-> "Apprend par cœur" les données d'entraînement
-> Très bonne perf sur TRAIN, mauvaise sur TEST
-> Solution : Régularisation, plus de données, simplifier

LE COMPROMIS BIAIS-VARIANCE :
            Erreur = Biais² + Variance + Bruit_irréductible

Modèle simple  -> Fort biais, faible variance (underfitting)
Modèle complexe -> Faible biais, forte variance (overfitting)
L'objectif : Équilibre optimal

VISUALISATION CONCEPTUELLE :
          Complexité du modèle ->
Erreur  ^
        │        ⟋ Erreur test (U-shape)
        │   ⟍  ⟋
        │    ⟍⟋
        │    ╱⟍
        │  ⟋    ⟍ Erreur train (décroissante)
        └────────────────
              ^
        Optimal


LE SPLIT TRAIN/TEST : RÈGLE D'OR

POURQUOI SÉPARER ?
-> Mesurer la VRAIE performance sur des données inconnues
-> Éviter l'optimisme trompeur de l'overfitting

LA RÈGLE :
Ne JAMAIS utiliser les données de test pour :
- Choisir les hyperparamètres
- Sélectionner les features
- Normaliser les données (fit sur test -> Data Leakage !)

PROPORTIONS TYPIQUES :
60/20/20 -> Train/Validation/Test
70/30    -> Train/Test (validation croisée sur train)
80/20    -> Train/Test (grand dataset)
"""


# ----------------------------------------------------------------------------
# [GRAPHIQUE] DATASET D'EXEMPLE
# ----------------------------------------------------------------------------

# Dataset de classification : Prédire le succès d'un étudiant
np.random.seed(42)
n = 1000

df_ml = pd.DataFrame({
    "note_math": np.clip(np.random.normal(13, 3, n), 0, 20).round(1),
    "note_info": np.clip(np.random.normal(14, 2.5, n), 0, 20).round(1),
    "note_science": np.clip(np.random.normal(12, 3.5, n), 0, 20).round(1),
    "heures_étude": np.clip(np.random.normal(20, 8, n), 0, 50).round(0),
    "age": np.random.randint(18, 28, n),
    "filière": np.random.choice(["Informatique", "Maths", "Physique", "Chimie"], n),
    "bourse": np.random.choice([0, 1], n, p=[0.7, 0.3]),
})

# Target : Succès (1) ou Échec (0) basé sur la moyenne
df_ml["moyenne"] = df_ml[["note_math", "note_info", "note_science"]].mean(axis=1).round(2)
df_ml["succès"] = (df_ml["moyenne"] >= 12).astype(int)

# Ajouter un peu de bruit pour rendre le problème réaliste
noise_idx = np.random.choice(n, int(n * 0.05))
df_ml.loc[noise_idx, "succès"] = 1 - df_ml.loc[noise_idx, "succès"]

print(f"Dataset créé : {df_ml.shape}")
print(f"\nRépartition de la cible :")
print(df_ml["succès"].value_counts())
print(f"\nTaux de succès : {df_ml['succès'].mean()*100:.1f}%")


# ============================================================================
# [GUIDE] CHAPITRE 16 : SCIKIT-LEARN - FONDAMENTAUX
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Maîtriser l'API unifiée de scikit-learn (fit/predict/transform)
[OK] Préparer les données correctement pour le ML
[OK] Construire des pipelines robustes
[OK] Effectuer une validation croisée
[OK] Implémenter le workflow complet de ML
"""


# ----------------------------------------------------------------------------
# [CONSTRUCTION] L'API UNIFIÉE DE SCIKIT-LEARN
# ----------------------------------------------------------------------------

"""
SCIKIT-LEARN A UNE API COHÉRENTE POUR TOUS LES MODÈLES

ESTIMATEURS (Modèles, Transformeurs) :

1. fit(X, y)        -> Apprendre sur les données
2. predict(X)       -> Faire des prédictions (modèles)
3. transform(X)     -> Transformer les données (scaler, encoder)
4. fit_transform(X) -> Combiner fit + transform (plus efficace)
5. score(X, y)      -> Évaluer le modèle

PATTERN UNIVERSEL :

from sklearn.FAMILLE import ModèleXYZ

model = ModèleXYZ(hyperparametre1=val1, hyperparametre2=val2)
model.fit(X_train, y_train)
y_pred = model.predict(X_test)
score = model.score(X_test, y_test)

C'est TOUJOURS la même structure !

FAMILLES PRINCIPALES :
sklearn.linear_model   -> Régression linéaire, logistique, Ridge, Lasso
sklearn.tree           -> Arbres de décision
sklearn.ensemble       -> Random Forest, Gradient Boosting, AdaBoost
sklearn.svm            -> Support Vector Machines
sklearn.neighbors      -> K-Nearest Neighbors
sklearn.cluster        -> K-Means, DBSCAN, Hiérarchique
sklearn.decomposition  -> PCA, NMF
sklearn.preprocessing  -> Scalers, Encoders
sklearn.model_selection-> Train/test split, Cross-validation, GridSearchCV
sklearn.metrics        -> Métriques d'évaluation
sklearn.pipeline       -> Pipelines
"""


# ----------------------------------------------------------------------------
# [OUTIL] PRÉPARATION DES DONNÉES
# ----------------------------------------------------------------------------

# Définir features et target
features_num = ["note_math", "note_info", "note_science", "heures_étude", "age"]
features_cat = ["filière"]
target = "succès"

X = df_ml[features_num + features_cat]
y = df_ml[target]

# ─── SPLIT TRAIN/TEST ───
"""
TRAIN/TEST SPLIT

stratify=y -> Important pour la classification !
-> Maintenir la même proportion de classes dans train et test
-> Évite les déséquilibres accidentels

random_state -> Pour reproductibilité
test_size -> 0.2 = 20% pour test, 80% pour train
"""
X_train, X_test, y_train, y_test = train_test_split(
    X, y,
    test_size=0.20,
    random_state=42,
    stratify=y  # Stratifié pour classification
)

print("\n" + "="*60)
print("SPLIT TRAIN/TEST")
print("="*60)
print(f"Train : {X_train.shape[0]} exemples ({X_train.shape[0]/len(X)*100:.0f}%)")
print(f"Test  : {X_test.shape[0]} exemples ({X_test.shape[0]/len(X)*100:.0f}%)")
print(f"\nRépartition cible dans train : {y_train.mean():.3f}")
print(f"Répartition cible dans test  : {y_test.mean():.3f}")
print("(Doivent être similaires grâce à stratify=y [OK])")


# ----------------------------------------------------------------------------
# [CONSTRUCTION] PIPELINES : LA BONNE PRATIQUE
# ----------------------------------------------------------------------------

"""
POURQUOI DES PIPELINES ?

SANS PIPELINE (problématique) :
  scaler.fit(X_train)
  X_train_scaled = scaler.transform(X_train)
  X_test_scaled = scaler.transform(X_test)
  model.fit(X_train_scaled, y_train)
  y_pred = model.predict(X_test_scaled)

-> Verbeux
-> Risque d'oublier de scaler le test
-> Data leakage possible

AVEC PIPELINE (recommandé) :
  pipeline = Pipeline([('scaler', scaler), ('model', model)])
  pipeline.fit(X_train, y_train)
  y_pred = pipeline.predict(X_test)

-> Propre et concis
-> Pas de risque de data leakage
-> Facilite la validation croisée et GridSearch
-> Déployable facilement (1 seul objet)

[ATTENTION] RÈGLE ABSOLUE : Le pipeline ne doit être fit() que sur X_train
"""

from sklearn.linear_model import LogisticRegression

# Pipeline complet
preprocessor = ColumnTransformer(transformers=[
    ("num", Pipeline([
        ("imputer", SimpleImputer(strategy="median")),
        ("scaler", StandardScaler())
    ]), features_num),
    ("cat", Pipeline([
        ("imputer", SimpleImputer(strategy="most_frequent")),
        ("encoder", OneHotEncoder(drop="first", handle_unknown="ignore"))
    ]), features_cat)
])

pipeline_lr = Pipeline([
    ("preprocessor", preprocessor),
    ("classifier", LogisticRegression(max_iter=1000, random_state=42))
])

# Entraînement (une seule ligne !)
pipeline_lr.fit(X_train, y_train)

# Évaluation
y_pred = pipeline_lr.predict(X_test)
y_pred_proba = pipeline_lr.predict_proba(X_test)[:, 1]

print("\n" + "="*60)
print("PREMIER MODÈLE : RÉGRESSION LOGISTIQUE")
print("="*60)
print(f"Accuracy : {accuracy_score(y_test, y_pred):.4f}")
print(f"AUC-ROC  : {roc_auc_score(y_test, y_pred_proba):.4f}")


# ----------------------------------------------------------------------------
# [SYNC] VALIDATION CROISÉE
# ----------------------------------------------------------------------------

"""
VALIDATION CROISÉE (Cross-Validation)

PROBLÈME du simple split :
-> La performance dépend de COMMENT on a splitté
-> Peut sur/sous-estimer la vraie performance

SOLUTION : K-FOLD CROSS-VALIDATION

1. Diviser les données en K parties (folds)
2. Pour chaque fold i :
   - Entraîner sur les K-1 autres folds
   - Évaluer sur le fold i
3. Moyenner les K scores

K = 5 ou 10 généralement

VISUALISATION (K=5) :

Fold 1 : [TEST][TRAIN][TRAIN][TRAIN][TRAIN] -> Score 1
Fold 2 : [TRAIN][TEST][TRAIN][TRAIN][TRAIN] -> Score 2
Fold 3 : [TRAIN][TRAIN][TEST][TRAIN][TRAIN] -> Score 3
Fold 4 : [TRAIN][TRAIN][TRAIN][TEST][TRAIN] -> Score 4
Fold 5 : [TRAIN][TRAIN][TRAIN][TRAIN][TEST] -> Score 5

Score final = Moyenne(Score 1,...,Score 5) ± Écart-type

AVANTAGES :
-> Utilise TOUTES les données pour l'entraînement et le test
-> Estimation plus fiable et robuste
-> Donne une idée de la variance du score

STRATIFIED K-FOLD :
-> Maintient la proportion des classes dans chaque fold
-> Indispensable pour la classification déséquilibrée
"""

print("\n" + "="*60)
print("VALIDATION CROISÉE (5-FOLD STRATIFIÉE)")
print("="*60)

# Cross-validation avec pipeline complet
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

scores_acc = cross_val_score(pipeline_lr, X_train, y_train, cv=cv, scoring="accuracy")
scores_auc = cross_val_score(pipeline_lr, X_train, y_train, cv=cv, scoring="roc_auc")
scores_f1 = cross_val_score(pipeline_lr, X_train, y_train, cv=cv, scoring="f1")

print(f"\nAccuracy   : {scores_acc.mean():.4f} ± {scores_acc.std():.4f}")
print(f"  Folds : {[f'{s:.4f}' for s in scores_acc]}")
print(f"\nAUC-ROC    : {scores_auc.mean():.4f} ± {scores_auc.std():.4f}")
print(f"  Folds : {[f'{s:.4f}' for s in scores_auc]}")
print(f"\nF1-Score   : {scores_f1.mean():.4f} ± {scores_f1.std():.4f}")


# ============================================================================
# [GUIDE] CHAPITRE 17 : MODÈLES DE CLASSIFICATION
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Régression logistique (avec régularisation)
[OK] Arbres de décision
[OK] Random Forest
[OK] Gradient Boosting (XGBoost)
[OK] Support Vector Machines (SVM)
[OK] K-Nearest Neighbors (KNN)
[OK] Comparer les modèles rigoureusement
"""


# ----------------------------------------------------------------------------
# [GRAPHIQUE] MÉTRIQUES DE CLASSIFICATION
# ----------------------------------------------------------------------------

"""
COMPRENDRE LES MÉTRIQUES DE CLASSIFICATION

MATRICE DE CONFUSION :

                 Prédit Négatif  |  Prédit Positif
Réel Négatif :       TN         |       FP
Réel Positif :       FN         |       TP

TN = True Negative  -> Bien prédit négatif
TP = True Positive  -> Bien prédit positif
FP = False Positive -> Faussement prédit positif (Erreur Type I)
FN = False Negative -> Faussement prédit négatif (Erreur Type II)

MÉTRIQUES DÉRIVÉES :

Accuracy (Exactitude) = (TP + TN) / Total
-> Proportion de bonnes prédictions
-> [ATTENTION] Trompeuse si classes déséquilibrées
-> Exemple : 99% négatifs -> Accuracy 99% en prédisant toujours négatif !

Precision (Précision) = TP / (TP + FP)
-> "Parmi les prédictions positives, combien sont vraiment positives ?"
-> Important quand le coût des FP est élevé
-> Ex: Faux positif = traitement inutile

Recall (Rappel/Sensibilité) = TP / (TP + FN)
-> "Parmi les vrais positifs, combien avons-nous détecté ?"
-> Important quand le coût des FN est élevé
-> Ex: Faux négatif = maladie non détectée

F1-Score = 2 × (Precision × Recall) / (Precision + Recall)
-> Moyenne harmonique de Precision et Recall
-> Bon compromis quand les deux comptent

AUC-ROC (Area Under ROC Curve) :
-> Performance à tous les seuils de décision
-> 0.5 = Modèle aléatoire
-> 1.0 = Modèle parfait
-> > 0.7 = Acceptable
-> > 0.8 = Bon
-> > 0.9 = Excellent


QUEL MÉTRIQUE CHOISIR ?

Classes équilibrées           -> Accuracy
Classes déséquilibrées        -> F1-Score, AUC-ROC
FP coûteux (spam detection)   -> Precision
FN coûteux (maladie/fraude)   -> Recall
"""


# ----------------------------------------------------------------------------
# [BOT] COMPARAISON DE MODÈLES
# ----------------------------------------------------------------------------

from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import (
    RandomForestClassifier, GradientBoostingClassifier,
    AdaBoostClassifier
)
from sklearn.svm import SVC
from sklearn.neighbors import KNeighborsClassifier
from sklearn.naive_bayes import GaussianNB

# Définir tous les modèles à comparer
modeles = {
    "Logistic Regression": LogisticRegression(max_iter=1000, random_state=42),
    "Decision Tree": DecisionTreeClassifier(random_state=42),
    "Random Forest": RandomForestClassifier(n_estimators=100, random_state=42),
    "Gradient Boosting": GradientBoostingClassifier(n_estimators=100, random_state=42),
    "SVM": SVC(probability=True, random_state=42),
    "KNN": KNeighborsClassifier(n_neighbors=5),
    "Naive Bayes": GaussianNB(),
}

# Comparer avec validation croisée
print("\n" + "="*60)
print("COMPARAISON DE TOUS LES MODÈLES")
print("="*60)

resultats = []
for nom, modele in modeles.items():
    pipeline = Pipeline([
        ("preprocessor", preprocessor),
        ("classifier", modele)
    ])

    acc = cross_val_score(pipeline, X_train, y_train, cv=cv, scoring="accuracy")
    auc = cross_val_score(pipeline, X_train, y_train, cv=cv, scoring="roc_auc")
    f1 = cross_val_score(pipeline, X_train, y_train, cv=cv, scoring="f1")

    resultats.append({
        "Modèle": nom,
        "Accuracy": acc.mean(),
        "Accuracy_std": acc.std(),
        "AUC-ROC": auc.mean(),
        "AUC-ROC_std": auc.std(),
        "F1-Score": f1.mean(),
        "F1-Score_std": f1.std(),
    })

    print(f"\n{nom:25} | Acc={acc.mean():.3f}±{acc.std():.3f} | "
          f"AUC={auc.mean():.3f}±{auc.std():.3f} | "
          f"F1={f1.mean():.3f}±{f1.std():.3f}")

df_resultats = pd.DataFrame(resultats).sort_values("AUC-ROC", ascending=False)
print(f"\n[TROPHEE] Meilleur modèle (AUC-ROC) : {df_resultats.iloc[0]['Modèle']}")

# Visualiser la comparaison
fig, axes = plt.subplots(1, 3, figsize=(16, 6))
fig.suptitle("Comparaison des Modèles de Classification", fontsize=14, fontweight="bold")

métriques = ["Accuracy", "AUC-ROC", "F1-Score"]
for ax, metrique in zip(axes, métriques):
    df_plot = df_resultats.sort_values(metrique)
    bars = ax.barh(df_plot["Modèle"], df_plot[metrique],
                   xerr=df_plot[f"{metrique}_std"],
                   color=cm.viridis(np.linspace(0.2, 0.8, len(df_plot))),
                   capsize=4, alpha=0.8)
    ax.set_xlabel(metrique)
    ax.set_title(metrique, fontweight="bold")
    ax.axvline(x=0.5, color="red", ls=":", alpha=0.7)  # Seuil aléatoire
    for bar, val in zip(bars, df_plot[metrique]):
        ax.text(val + 0.005, bar.get_y() + bar.get_height()/2,
                f"{val:.3f}", va="center", fontsize=8)

plt.tight_layout()
plt.savefig("ch17_comparaison_modeles.png", dpi=150, bbox_inches="tight")
plt.show()


# ----------------------------------------------------------------------------
# [ARBRE] RANDOM FOREST - ANALYSE APPROFONDIE
# ----------------------------------------------------------------------------

"""
RANDOM FOREST : EXPLICATION INTUITIVE

"La sagesse de la foule"

PROBLÈME des arbres de décision simples :
-> Overfitting sévère (apprend par cœur le train)
-> Très sensibles aux variations des données

SOLUTION : ENSEMBLER beaucoup d'arbres différents

COMMENT ÇA MARCHE :

1. BAGGING (Bootstrap Aggregating) :
   -> Créer N sous-ensembles aléatoires du dataset (avec remise)
   -> Entraîner 1 arbre sur chaque sous-ensemble

2. FEATURE RANDOMNESS :
   -> À chaque nœud, ne considérer qu'un SOUS-ENSEMBLE aléatoire de features
   -> Rend les arbres DIFFÉRENTS (décorrélés)

3. AGRÉGATION :
   -> Pour classification : VOTE MAJORITAIRE des N arbres
   -> Pour régression : MOYENNE des N arbres

AVANTAGES :
[OK] Résistant à l'overfitting
[OK] Gère les valeurs manquantes
[OK] Donne l'importance des features
[OK] Peu d'hyperparamètres à tuner
[OK] Parallélisable

INCONVÉNIENTS :
[X] Moins interprétable qu'un arbre simple
[X] Plus lent (mais parallélisable)
[X] Beaucoup de mémoire pour grands datasets


HYPERPARAMÈTRES IMPORTANTS :
n_estimators  -> Nombre d'arbres (plus = mieux mais plus lent)
max_depth     -> Profondeur max (None = infini -> overfitting)
min_samples_split -> Nb min d'exemples pour split
min_samples_leaf  -> Nb min d'exemples dans feuille
max_features  -> "sqrt" pour classification, "auto" pour régression
"""

# Entraîner Random Forest
rf = Pipeline([
    ("preprocessor", preprocessor),
    ("classifier", RandomForestClassifier(
        n_estimators=200,
        max_depth=None,
        min_samples_split=2,
        min_samples_leaf=1,
        max_features="sqrt",
        random_state=42,
        n_jobs=-1  # Paralléliser
    ))
])

rf.fit(X_train, y_train)
y_pred_rf = rf.predict(X_test)
y_proba_rf = rf.predict_proba(X_test)[:, 1]

print("\n" + "="*60)
print("RANDOM FOREST - ANALYSE COMPLÈTE")
print("="*60)
print(f"\nAccuracy : {accuracy_score(y_test, y_pred_rf):.4f}")
print(f"AUC-ROC  : {roc_auc_score(y_test, y_proba_rf):.4f}")
print(f"\nRapport de classification :")
print(classification_report(y_test, y_pred_rf, target_names=["Échec", "Succès"]))

# Matrice de confusion
cm_rf = confusion_matrix(y_test, y_pred_rf)
print("Matrice de confusion :")
print(cm_rf)

# Feature Importance
rf_model = rf.named_steps["classifier"]
preprocessor_fit = rf.named_steps["preprocessor"]

# Noms des features après encodage
num_names = features_num
cat_names = preprocessor_fit.named_transformers_["cat"]["encoder"].get_feature_names_out(features_cat).tolist()
all_feature_names = num_names + cat_names

importances = rf_model.feature_importances_

# Visualisation Feature Importance + Courbe ROC
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
fig.suptitle("Random Forest - Analyse", fontsize=14, fontweight="bold")

# 1. Feature Importance
if len(importances) == len(all_feature_names):
    importance_df = pd.DataFrame({
        "Feature": all_feature_names,
        "Importance": importances
    }).sort_values("Importance", ascending=True)

    axes[0].barh(importance_df["Feature"], importance_df["Importance"],
                 color="steelblue", alpha=0.8)
    axes[0].set_title("Feature Importance")
    axes[0].set_xlabel("Importance (Gini)")

# 2. Courbe ROC
fpr, tpr, _ = roc_curve(y_test, y_proba_rf)
auc = roc_auc_score(y_test, y_proba_rf)
axes[1].plot(fpr, tpr, color="steelblue", lw=2.5, label=f"Random Forest (AUC = {auc:.3f})")
axes[1].plot([0, 1], [0, 1], "k--", label="Aléatoire (AUC = 0.500)")
axes[1].fill_between(fpr, tpr, alpha=0.1, color="steelblue")
axes[1].set_xlabel("Taux Faux Positifs (FPR)")
axes[1].set_ylabel("Taux Vrais Positifs (TPR)")
axes[1].set_title("Courbe ROC")
axes[1].legend()
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig("ch17_random_forest.png", dpi=150, bbox_inches="tight")
plt.show()


# ----------------------------------------------------------------------------
# [RAPIDE] GRADIENT BOOSTING (XGBoost style)
# ----------------------------------------------------------------------------

"""
GRADIENT BOOSTING : L'ALGORITHME LE PLUS PUISSANT

DIFFÉRENCE avec Random Forest :
- Random Forest : Arbres en PARALLÈLE (indépendants)
- Gradient Boosting : Arbres en SÉQUENCE (chaque arbre corrige le précédent)

COMMENT ÇA MARCHE :

1. Initialiser avec une prédiction simple (moyenne pour régression)
2. Calculer les RÉSIDUS (erreurs du modèle actuel)
3. Entraîner un nouvel arbre sur les RÉSIDUS
4. Ajouter ce nouvel arbre au modèle (avec taux d'apprentissage)
5. Répéter N fois

INTUITION :
-> Chaque arbre se "spécialise" dans les erreurs des précédents
-> "Booster" progressivement les performances

AVANTAGES :
[OK] Souvent le meilleur algorithme sur données tabulaires
[OK] Gère les valeurs manquantes (XGBoost)
[OK] Interprétable (feature importance)

INCONVÉNIENTS :
[X] Plus d'hyperparamètres à tuner
[X] Plus sensible à l'overfitting
[X] Plus lent à entraîner

HYPERPARAMÈTRES CRITIQUES :
n_estimators     -> Nombre de "boosting rounds"
learning_rate    -> Contribution de chaque arbre (0.01-0.3)
max_depth        -> Profondeur des arbres (3-8)
subsample        -> Fraction des données pour chaque arbre
min_samples_leaf -> Contrôle l'overfitting
"""

# Tenter d'utiliser XGBoost si disponible
try:
    from xgboost import XGBClassifier
    xgb_model = XGBClassifier(
        n_estimators=200,
        learning_rate=0.1,
        max_depth=5,
        subsample=0.8,
        colsample_bytree=0.8,
        random_state=42,
        eval_metric="logloss",
        verbosity=0
    )
    USE_XGB = True
    print("[OK] XGBoost disponible")
except ImportError:
    USE_XGB = False
    print("[ATTENTION] XGBoost non installé. pip install xgboost")
    print("Utilisation de sklearn GradientBoosting")

# Gradient Boosting sklearn
gb_pipeline = Pipeline([
    ("preprocessor", preprocessor),
    ("classifier", GradientBoostingClassifier(
        n_estimators=200,
        learning_rate=0.05,
        max_depth=4,
        subsample=0.8,
        random_state=42
    ))
])

gb_pipeline.fit(X_train, y_train)
y_pred_gb = gb_pipeline.predict(X_test)
y_proba_gb = gb_pipeline.predict_proba(X_test)[:, 1]

print("\n" + "="*60)
print("GRADIENT BOOSTING")
print("="*60)
print(f"Accuracy : {accuracy_score(y_test, y_pred_gb):.4f}")
print(f"AUC-ROC  : {roc_auc_score(y_test, y_proba_gb):.4f}")
print(f"F1-Score : {f1_score(y_test, y_pred_gb):.4f}")


# ============================================================================
# [GUIDE] CHAPITRE 18 : MODÈLES DE RÉGRESSION
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Régression linéaire avec régularisation (Ridge, Lasso, ElasticNet)
[OK] Régression avec arbres (Random Forest, Gradient Boosting)
[OK] Métriques de régression (MAE, MSE, RMSE, R², MAPE)
[OK] Courbes d'apprentissage (learning curves)
[OK] Diagnostic de régression
"""


# ----------------------------------------------------------------------------
# [GRAPHIQUE] MÉTRIQUES DE RÉGRESSION
# ----------------------------------------------------------------------------

"""
MÉTRIQUES DE RÉGRESSION

MAE (Mean Absolute Error) :
MAE = Σ|yi - ŷi| / n
-> Moyenne des erreurs absolues
-> MÊME UNITÉ que y
-> Robuste aux outliers
-> Interprétable : "En moyenne, on se trompe de X unités"

MSE (Mean Squared Error) :
MSE = Σ(yi - ŷi)² / n
-> Pénalise PLUS les grandes erreurs
-> Sensible aux outliers
-> Différentiable (utile pour l'optimisation)

RMSE (Root MSE) :
RMSE = √MSE
-> MÊME UNITÉ que y
-> Toujours ≥ MAE
-> Le plus utilisé en pratique

R² (Coefficient de Détermination) :
R² = 1 - SS_res/SS_tot
-> Proportion de variance expliquée par le modèle
-> 0 = Modèle nul (prédit la moyenne)
-> 1 = Modèle parfait
-> Peut être négatif si le modèle est pire que la moyenne !

MAPE (Mean Absolute Percentage Error) :
MAPE = Σ|yi - ŷi|/|yi| × 100%
-> Erreur relative en %
-> [ATTENTION] Problème si yi ≈ 0

CHOISIR LE BON MÉTRIQUE :
- Toujours commencer par R² (vue d'ensemble)
- RMSE si les grandes erreurs sont très problématiques
- MAE si on veut une interprétation intuitive
- MAPE pour comparer des modèles sur des variables d'échelles différentes
"""


# ----------------------------------------------------------------------------
# [OUTIL] RÉGULARISATION
# ----------------------------------------------------------------------------

"""
PROBLÈME DE L'OVERFITTING EN RÉGRESSION

Régression simple -> Peut overfitter avec trop de features

SOLUTION : RÉGULARISATION (pénaliser les coefficients)

RIDGE (L2) :
Minimiser : MSE + λ × Σβi²
-> Réduit les coefficients VERS 0 (mais pas exactement 0)
-> Garde TOUTES les features
-> Bon si toutes les features sont utiles

LASSO (L1) :
Minimiser : MSE + λ × Σ|βi|
-> Peut mettre des coefficients EXACTEMENT à 0
-> Sélection de features automatique !
-> Bon pour la parcimonie (sparse models)

ELASTICNET (L1 + L2) :
Minimiser : MSE + λ1 × Σ|βi| + λ2 × Σβi²
-> Combine Ridge et Lasso
-> Plus flexible, bon compromis

HYPERPARAMÈTRE α (ou λ) :
α grand -> Forte régularisation -> Underfitting
α petit -> Faible régularisation -> Overfitting
-> Trouver le bon α avec Cross-Validation
"""

from sklearn.linear_model import LinearRegression, Ridge, Lasso, ElasticNet
from sklearn.ensemble import RandomForestRegressor, GradientBoostingRegressor
from sklearn.svm import SVR
from sklearn.tree import DecisionTreeRegressor

# Dataset pour régression (prédire la moyenne)
X_reg = df_ml[features_num + features_cat]
y_reg = df_ml["moyenne"]

X_train_r, X_test_r, y_train_r, y_test_r = train_test_split(
    X_reg, y_reg, test_size=0.20, random_state=42
)

# Modèles de régression
modeles_reg = {
    "Linear Regression": LinearRegression(),
    "Ridge (α=1)": Ridge(alpha=1.0),
    "Lasso (α=0.1)": Lasso(alpha=0.1),
    "ElasticNet": ElasticNet(alpha=0.1, l1_ratio=0.5),
    "Decision Tree": DecisionTreeRegressor(max_depth=5, random_state=42),
    "Random Forest": RandomForestRegressor(n_estimators=100, random_state=42, n_jobs=-1),
    "Gradient Boosting": GradientBoostingRegressor(n_estimators=100, random_state=42),
}

print("\n" + "="*60)
print("COMPARAISON DES MODÈLES DE RÉGRESSION")
print("="*60)

resultats_reg = []
for nom, modele in modeles_reg.items():
    pipeline = Pipeline([
        ("preprocessor", preprocessor),
        ("model", modele)
    ])

    cv_scores = cross_val_score(pipeline, X_train_r, y_train_r, cv=5, scoring="r2")
    pipeline.fit(X_train_r, y_train_r)
    y_pred = pipeline.predict(X_test_r)

    rmse = np.sqrt(mean_squared_error(y_test_r, y_pred))
    mae = mean_absolute_error(y_test_r, y_pred)
    r2 = r2_score(y_test_r, y_pred)

    resultats_reg.append({
        "Modèle": nom,
        "R² (CV)": cv_scores.mean(),
        "R² Test": r2,
        "RMSE": rmse,
        "MAE": mae
    })

    print(f"{nom:25} | R²(CV)={cv_scores.mean():.3f} | R²={r2:.3f} | RMSE={rmse:.3f} | MAE={mae:.3f}")

df_reg_results = pd.DataFrame(resultats_reg).sort_values("R² Test", ascending=False)
print(f"\n[TROPHEE] Meilleur modèle : {df_reg_results.iloc[0]['Modèle']} (R²={df_reg_results.iloc[0]['R² Test']:.3f})")


# ----------------------------------------------------------------------------
# [HAUSSE] COURBES D'APPRENTISSAGE
# ----------------------------------------------------------------------------

"""
COURBES D'APPRENTISSAGE (Learning Curves)

POURQUOI ?
-> Diagnostiquer l'underfitting et l'overfitting
-> Savoir si plus de données aiderait

LECTURE :
         Score
    1.0 ─────────────────────────
         Train ─────────────────
                                │ Ecart = Overfitting
                   Test  ───────┤
    0.5 ─────────────────────────
              N (taille dataset) ->

SI train >> test -> Overfitting
Si train ≈ test ≈ bas -> Underfitting
Si train ≈ test ≈ élevé -> Bon modèle

SI les courbes ne convergent pas -> Plus de données aiderait
"""

rf_reg = Pipeline([
    ("preprocessor", preprocessor),
    ("model", RandomForestRegressor(n_estimators=50, random_state=42))
])

train_sizes, train_scores, test_scores = learning_curve(
    rf_reg, X_train_r, y_train_r,
    cv=5, n_jobs=-1,
    train_sizes=np.linspace(0.1, 1.0, 10),
    scoring="r2"
)

fig, ax = plt.subplots(figsize=(10, 6))

train_mean = train_scores.mean(axis=1)
train_std = train_scores.std(axis=1)
test_mean = test_scores.mean(axis=1)
test_std = test_scores.std(axis=1)

ax.plot(train_sizes, train_mean, "b-", lw=2.5, label="Score Train")
ax.fill_between(train_sizes, train_mean - train_std, train_mean + train_std, alpha=0.15, color="blue")
ax.plot(train_sizes, test_mean, "r-", lw=2.5, label="Score Validation")
ax.fill_between(train_sizes, test_mean - test_std, test_mean + test_std, alpha=0.15, color="red")

ax.set_xlabel("Taille du dataset d'entraînement")
ax.set_ylabel("R²")
ax.set_title("Courbes d'Apprentissage - Random Forest Régression", fontweight="bold")
ax.legend()
ax.grid(True, alpha=0.3)
ax.axhline(y=1.0, color="gray", ls=":", alpha=0.5)

plt.tight_layout()
plt.savefig("ch18_learning_curves.png", dpi=150, bbox_inches="tight")
plt.show()


# ============================================================================
# [GUIDE] CHAPITRE 19 : CLUSTERING ET RÉDUCTION DE DIMENSIONS
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Appliquer K-Means et choisir le bon k (Elbow + Silhouette)
[OK] Comprendre DBSCAN (clustering basé sur la densité)
[OK] Clustering hiérarchique et dendrogrammes
[OK] PCA (Analyse en Composantes Principales)
[OK] t-SNE et UMAP pour la visualisation
"""


# ----------------------------------------------------------------------------
# [BLEU] K-MEANS CLUSTERING
# ----------------------------------------------------------------------------

"""
K-MEANS : L'ALGORITHME DE CLUSTERING LE PLUS UTILISÉ

ALGORITHME :
1. Initialiser K centroïdes aléatoirement
2. Assigner chaque point au centroïde le plus proche
3. Recalculer les centroïdes (moyenne des points assignés)
4. Répéter 2-3 jusqu'à convergence

ANALOGIE :
Vous avez 1000 clients et voulez les segmenter en 5 groupes.
K-Means trouve automatiquement les groupes "naturels".

CHOISIR K :

MÉTHODE ELBOW :
-> Tracer l'inertie (somme des distances au centroïde) pour K=1..15
-> Chercher le "coude" où la diminution ralentit

SILHOUETTE SCORE :
-> Mesure à quel point chaque point est "proche de son cluster"
-> et "éloigné des autres clusters"
-> -1 (mauvais) à +1 (excellent)
-> Choisir K qui maximise le score

LIMITATIONS DE K-MEANS :
-> K doit être spécifié à l'avance
-> Suppose des clusters SPHÉRIQUES
-> Sensible aux outliers
-> Sensible à l'initialisation (utiliser n_init=10)
"""

from sklearn.cluster import KMeans, DBSCAN, AgglomerativeClustering
from scipy.cluster.hierarchy import dendrogram, linkage

# Données pour clustering (seulement numériques)
X_cluster = df_ml[["note_math", "note_info", "note_science", "heures_étude"]].dropna()

# Normaliser (indispensable pour K-Means !)
scaler_clust = StandardScaler()
X_cluster_scaled = scaler_clust.fit_transform(X_cluster)

# MÉTHODE ELBOW
print("\n" + "="*60)
print("K-MEANS - MÉTHODE ELBOW")
print("="*60)

inertias = []
silhouettes = []
K_range = range(2, 11)

for k in K_range:
    kmeans = KMeans(n_clusters=k, n_init=10, random_state=42)
    labels = kmeans.fit_predict(X_cluster_scaled)
    inertias.append(kmeans.inertia_)
    sil = silhouette_score(X_cluster_scaled, labels)
    silhouettes.append(sil)
    print(f"  k={k} : Inertie={kmeans.inertia_:.1f}, Silhouette={sil:.4f}")

# Visualiser Elbow et Silhouette
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
fig.suptitle("Choisir K optimal", fontsize=14, fontweight="bold")

axes[0].plot(K_range, inertias, "bo-", lw=2.5, markersize=8)
axes[0].set_xlabel("Nombre de clusters K")
axes[0].set_ylabel("Inertie (Within-Cluster Sum of Squares)")
axes[0].set_title("Méthode Elbow")
axes[0].grid(True, alpha=0.3)

axes[1].plot(K_range, silhouettes, "rs-", lw=2.5, markersize=8)
k_optimal = K_range[np.argmax(silhouettes)]
axes[1].axvline(x=k_optimal, color="green", ls="--", lw=2,
                label=f"K optimal = {k_optimal}")
axes[1].set_xlabel("Nombre de clusters K")
axes[1].set_ylabel("Silhouette Score")
axes[1].set_title("Score de Silhouette")
axes[1].legend()
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig("ch19_kmeans_elbow.png", dpi=150, bbox_inches="tight")
plt.show()

print(f"\n-> K optimal (Silhouette) : {k_optimal}")

# Entraîner K-Means final
kmeans_final = KMeans(n_clusters=k_optimal, n_init=10, random_state=42)
df_ml["cluster"] = kmeans_final.fit_predict(X_cluster_scaled)

print(f"\nRépartition des clusters :")
print(df_ml["cluster"].value_counts().sort_index())

print(f"\nCaractéristiques par cluster :")
print(df_ml.groupby("cluster")[["note_math", "note_info", "note_science", "heures_étude"]].mean().round(2))


# ----------------------------------------------------------------------------
# [RECHERCHE] DBSCAN - CLUSTERING BASÉ SUR LA DENSITÉ
# ----------------------------------------------------------------------------

"""
DBSCAN (Density-Based Spatial Clustering of Applications with Noise)

AVANTAGES vs K-Means :
[OK] Ne nécessite PAS de spécifier K
[OK] Découvre des clusters de FORME ARBITRAIRE
[OK] Détecte les OUTLIERS (points bruit)

FONCTIONNEMENT :
-> Un point est "noyau" (core point) si au moins min_samples
  points sont dans son voisinage de rayon eps
-> Les points noyaux s'expansent en clusters
-> Les points non atteignables = BRUIT (label = -1)

HYPERPARAMÈTRES :
eps        -> Rayon de voisinage (tester plusieurs valeurs)
min_samples-> Nb minimum de points pour être noyau (≈ 2×nb_features)

TROUVER eps :
-> Calculer les distances k-NN
-> Tracer en ordre croissant
-> Trouver le "coude"
"""

from sklearn.neighbors import NearestNeighbors

print("\n" + "="*60)
print("DBSCAN - CLUSTERING BASÉ SUR LA DENSITÉ")
print("="*60)

# Trouver eps optimal
k_nn = 4  # min_samples - 1
nbrs = NearestNeighbors(n_neighbors=k_nn).fit(X_cluster_scaled)
distances, _ = nbrs.kneighbors(X_cluster_scaled)
distances_sorted = np.sort(distances[:, -1])

# Appliquer DBSCAN
dbscan = DBSCAN(eps=0.8, min_samples=5)
labels_db = dbscan.fit_predict(X_cluster_scaled)

n_clusters_db = len(set(labels_db)) - (1 if -1 in labels_db else 0)
n_noise = (labels_db == -1).sum()

print(f"Paramètres : eps=0.8, min_samples=5")
print(f"Clusters trouvés : {n_clusters_db}")
print(f"Points bruit     : {n_noise} ({n_noise/len(labels_db)*100:.1f}%)")

if n_clusters_db > 1:
    mask_no_noise = labels_db != -1
    sil_db = silhouette_score(
        X_cluster_scaled[mask_no_noise],
        labels_db[mask_no_noise]
    )
    print(f"Silhouette score : {sil_db:.4f}")


# ----------------------------------------------------------------------------
# [MESURE] PCA - RÉDUCTION DE DIMENSIONS
# ----------------------------------------------------------------------------

"""
PCA (Principal Component Analysis)

POURQUOI RÉDUIRE LES DIMENSIONS ?

"La malédiction de la dimensionnalité" :
-> Plus de features -> Plus de données nécessaires
-> Les distances perdent leur sens
-> Visualisation impossible > 3D

PCA RÉSOUT CES PROBLÈMES :
-> Trouve les directions de maximum de variance
-> Projette les données sur ces directions
-> Composantes = combinations linéaires des features originales
-> Les composantes sont ORTHOGONALES (non corrélées)

COMMENT L'UTILISER :

1. Décider du nombre de composantes :
   -> Garder 95% de la variance expliquée
   -> Ou choisir par visualisation (coude dans variance_ratio_)

2. TOUJOURS normaliser avant PCA !

3. Les composantes PCA ne sont PAS interprétables directement
   (Ce sont des combinaisons linéaires des features)

ATTENTION : PCA est linéaire -> Ne capture pas les non-linéarités
Alternative non-linéaire : t-SNE, UMAP
"""

from sklearn.decomposition import PCA

# PCA complet d'abord
pca_full = PCA(random_state=42)
pca_full.fit(X_cluster_scaled)

# Variance expliquée
var_ratio = pca_full.explained_variance_ratio_
var_cumul = np.cumsum(var_ratio)

print("\n" + "="*60)
print("PCA - ANALYSE EN COMPOSANTES PRINCIPALES")
print("="*60)
print("\nVariance expliquée par composante :")
for i, (ratio, cumul) in enumerate(zip(var_ratio, var_cumul)):
    barre = "█" * int(ratio * 50)
    print(f"  PC{i+1}: {ratio*100:5.1f}% {barre} (Cumulée: {cumul*100:.1f}%)")

# Nombre de composantes pour 95% variance
n_comp_95 = np.argmax(var_cumul >= 0.95) + 1
print(f"\n-> Composantes pour 95% variance : {n_comp_95}")

# PCA 2D pour visualisation
pca_2d = PCA(n_components=2, random_state=42)
X_pca_2d = pca_2d.fit_transform(X_cluster_scaled)

fig, axes = plt.subplots(1, 2, figsize=(14, 6))
fig.suptitle("PCA - Visualisation", fontsize=14, fontweight="bold")

# 1. Variance expliquée
axes[0].bar(range(1, len(var_ratio)+1), var_ratio*100, color="steelblue", alpha=0.8)
axes[0].plot(range(1, len(var_cumul)+1), var_cumul*100, "r-o", lw=2, markersize=5)
axes[0].axhline(y=95, color="green", ls="--", label="95% variance")
axes[0].set_xlabel("Composante Principale")
axes[0].set_ylabel("Variance expliquée (%)")
axes[0].set_title("Variance expliquée par composante")
axes[0].legend()
axes[0].grid(True, alpha=0.3)

# 2. Projection 2D
couleurs_cluster = ["steelblue", "coral", "seagreen", "purple", "orange"]
for k in sorted(df_ml["cluster"].unique()):
    mask = df_ml["cluster"] == k
    axes[1].scatter(
        X_pca_2d[mask, 0], X_pca_2d[mask, 1],
        color=couleurs_cluster[k % len(couleurs_cluster)],
        label=f"Cluster {k}",
        alpha=0.5, s=20
    )
axes[1].set_xlabel(f"PC1 ({pca_2d.explained_variance_ratio_[0]*100:.1f}% variance)")
axes[1].set_ylabel(f"PC2 ({pca_2d.explained_variance_ratio_[1]*100:.1f}% variance)")
axes[1].set_title("Projection PCA 2D (colorée par K-Means)")
axes[1].legend()
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig("ch19_pca.png", dpi=150, bbox_inches="tight")
plt.show()


# ----------------------------------------------------------------------------
# [CYCLONE] T-SNE POUR VISUALISATION
# ----------------------------------------------------------------------------

"""
t-SNE (t-Distributed Stochastic Neighbor Embedding)

DIFFÉRENCE avec PCA :
-> PCA : Linéaire, global (maximise variance globale)
-> t-SNE : Non-linéaire, LOCAL (préserve les voisinages)

AVANTAGES :
[OK] Excellent pour visualiser des structures complexes
[OK] Révèle des clusters et sous-groupes

ATTENTION :
[ATTENTION] Les distances ENTRE clusters ne sont pas significatives
[ATTENTION] Seule la structure LOCALE est préservée
[ATTENTION] Ne pas utiliser pour la réduction de features (outil de viz uniquement)
[ATTENTION] L'hyperparamètre perplexity affecte beaucoup le résultat (5-50)

USAGE TYPIQUE : Visualisation de données text (NLP), images
"""

from sklearn.manifold import TSNE

print("\n" + "="*60)
print("t-SNE - VISUALISATION 2D")
print("="*60)

# t-SNE (lent pour grands datasets -> sous-échantillonner)
sample_idx = np.random.choice(len(X_cluster_scaled), min(500, len(X_cluster_scaled)), replace=False)
X_tsne_input = X_cluster_scaled[sample_idx]
clusters_sample = df_ml["cluster"].values[sample_idx]

tsne = TSNE(n_components=2, perplexity=30, random_state=42, n_iter=1000)
X_tsne = tsne.fit_transform(X_tsne_input)

fig, ax = plt.subplots(figsize=(10, 7))
for k in sorted(df_ml["cluster"].unique()):
    mask = clusters_sample == k
    ax.scatter(X_tsne[mask, 0], X_tsne[mask, 1],
               color=couleurs_cluster[k % len(couleurs_cluster)],
               label=f"Cluster {k}", alpha=0.6, s=30)
ax.set_title("t-SNE - Visualisation 2D des clusters", fontweight="bold")
ax.legend()
ax.set_xlabel("t-SNE Dimension 1")
ax.set_ylabel("t-SNE Dimension 2")
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("ch19_tsne.png", dpi=150, bbox_inches="tight")
plt.show()


# ============================================================================
# [GUIDE] CHAPITRE 20 : ÉVALUATION ET OPTIMISATION DES MODÈLES
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Tuner les hyperparamètres (GridSearchCV, RandomizedSearchCV)
[OK] Comprendre le problème d'optimisation des hyperparamètres
[OK] Gérer les données déséquilibrées
[OK] Créer des ensembles (stacking, voting)
[OK] Calibrer les probabilités
[OK] Interpréter les modèles (SHAP)
"""


# ----------------------------------------------------------------------------
# [RECHERCHE] OPTIMISATION DES HYPERPARAMÈTRES
# ----------------------------------------------------------------------------

"""
GRIDSEARCHCV vs RANDOMIZEDSEARCHCV

GRIDSEARCHCV :
-> Teste TOUTES les combinaisons possibles
-> Exhaustif mais LENT
-> Adapté si peu d'hyperparamètres
-> n_combinations = Πi len(param_i)

RANDOMIZEDSEARCHCV :
-> Tire aléatoirement N combinaisons
-> PLUS RAPIDE
-> Souvent presque aussi bon que GridSearch
-> Adapté si beaucoup d'hyperparamètres

RÈGLE PRATIQUE :
-> < 3 hyperparamètres avec peu de valeurs -> GridSearch
-> ≥ 3 hyperparamètres -> RandomizedSearch
-> Hyperparamètres continus (learning_rate) -> RandomizedSearch avec distributions

ATTENTION :
-> Toujours utiliser les données d'ENTRAÎNEMENT pour la recherche
-> Le test set ne sert QU'à l'évaluation finale
-> cv=5 dans GridSearchCV = validation croisée sur le train
"""

print("\n" + "="*60)
print("OPTIMISATION DES HYPERPARAMÈTRES - RANDOM FOREST")
print("="*60)

from scipy.stats import randint, uniform

# Pipeline avec preprocesseur
pipeline_opt = Pipeline([
    ("preprocessor", preprocessor),
    ("classifier", RandomForestClassifier(random_state=42, n_jobs=-1))
])

# ─── GridSearchCV ───
param_grid = {
    "classifier__n_estimators": [50, 100, 200],
    "classifier__max_depth": [None, 5, 10],
    "classifier__min_samples_split": [2, 5],
}

grid_search = GridSearchCV(
    pipeline_opt,
    param_grid,
    cv=StratifiedKFold(n_splits=5, shuffle=True, random_state=42),
    scoring="roc_auc",
    n_jobs=-1,
    verbose=1
)

print("\nGridSearchCV en cours...")
grid_search.fit(X_train, y_train)

print(f"\nMeilleurs paramètres :")
for param, val in grid_search.best_params_.items():
    print(f"  {param}: {val}")
print(f"Meilleur score CV : {grid_search.best_score_:.4f}")

# Évaluation finale
y_pred_best = grid_search.predict(X_test)
y_proba_best = grid_search.predict_proba(X_test)[:, 1]
print(f"\nScore sur TEST (jamais vu) :")
print(f"  Accuracy : {accuracy_score(y_test, y_pred_best):.4f}")
print(f"  AUC-ROC  : {roc_auc_score(y_test, y_proba_best):.4f}")
print(f"  F1       : {f1_score(y_test, y_pred_best):.4f}")

# ─── RandomizedSearchCV ───
print("\nRandomizedSearchCV :")

param_dist = {
    "classifier__n_estimators": randint(50, 300),
    "classifier__max_depth": [None, 3, 5, 7, 10, 15],
    "classifier__min_samples_split": randint(2, 20),
    "classifier__min_samples_leaf": randint(1, 10),
    "classifier__max_features": ["sqrt", "log2", 0.5, 0.7]
}

random_search = RandomizedSearchCV(
    pipeline_opt,
    param_distributions=param_dist,
    n_iter=30,             # Tester 30 combinaisons aléatoires
    cv=5,
    scoring="roc_auc",
    n_jobs=-1,
    random_state=42,
    verbose=0
)

random_search.fit(X_train, y_train)
print(f"Meilleur score RandomizedSearch : {random_search.best_score_:.4f}")


# ----------------------------------------------------------------------------
# [SCALES] GESTION DES DONNÉES DÉSÉQUILIBRÉES
# ----------------------------------------------------------------------------

"""
DONNÉES DÉSÉQUILIBRÉES (Imbalanced Data)

PROBLÈME :
Si 95% des emails sont légitimes et 5% sont du spam :
-> Un modèle qui prédit toujours "légitime" a 95% d'accuracy
-> Mais détecte 0% de spam !

EXEMPLES COURANTS :
- Fraude bancaire : 0.1% de fraudes
- Diagnostic médical : Maladies rares
- Détection d'anomalies
- Churns clients

SOLUTIONS :

1. CHANGER LA MÉTRIQUE
   -> Utiliser F1, AUC-ROC plutôt qu'accuracy

2. AJUSTER LE SEUIL DE DÉCISION
   -> Défaut = 0.5
   -> Pour rappel élevé (fraude) -> Baisser le seuil (0.3)

3. PONDÉRATION DES CLASSES (class_weight)
   -> sklearn : class_weight="balanced"
   -> Pénalise plus les erreurs sur la classe minoritaire

4. SURÉCHANTILLONNAGE (Oversampling)
   -> SMOTE : Crée des exemples synthétiques de la classe minoritaire

5. SOUS-ÉCHANTILLONNAGE (Undersampling)
   -> Réduire la classe majoritaire
   -> Perte d'information

MEILLEURE PRATIQUE :
-> D'abord class_weight="balanced" (simple et efficace)
-> Puis SMOTE si nécessaire
"""

# Créer dataset déséquilibré artificiel
np.random.seed(42)
n_fraud = 50       # 5% de fraudes
n_legit = 950      # 95% légitimes

X_fraud = pd.DataFrame({
    "montant": np.random.normal(500, 200, n_fraud),
    "fréquence": np.random.normal(10, 3, n_fraud),
    "pays_risqué": np.random.choice([0, 1], n_fraud, p=[0.3, 0.7])
})
X_legit = pd.DataFrame({
    "montant": np.random.normal(200, 100, n_legit),
    "fréquence": np.random.normal(3, 1, n_legit),
    "pays_risqué": np.random.choice([0, 1], n_legit, p=[0.9, 0.1])
})

X_imb = pd.concat([X_legit, X_fraud], ignore_index=True)
y_imb = pd.Series([0] * n_legit + [1] * n_fraud)

X_tr, X_te, y_tr, y_te = train_test_split(X_imb, y_imb, test_size=0.2,
                                            random_state=42, stratify=y_imb)

print("\n" + "="*60)
print("DONNÉES DÉSÉQUILIBRÉES - FRAUDE")
print("="*60)
print(f"Classe 0 (Légitime) : {y_imb.value_counts()[0]} ({y_imb.value_counts()[0]/len(y_imb)*100:.1f}%)")
print(f"Classe 1 (Fraude)   : {y_imb.value_counts()[1]} ({y_imb.value_counts()[1]/len(y_imb)*100:.1f}%)")

# Sans gestion du déséquilibre
rf_base = RandomForestClassifier(n_estimators=100, random_state=42)
rf_base.fit(X_tr, y_tr)
y_pred_base = rf_base.predict(X_te)

print(f"\nSANS gestion du déséquilibre :")
print(f"  Accuracy : {accuracy_score(y_te, y_pred_base):.4f}")
print(f"  Recall   : {recall_score(y_te, y_pred_base):.4f} <- Proportion de fraudes détectées")
print(f"  F1       : {f1_score(y_te, y_pred_base):.4f}")

# Avec class_weight="balanced"
rf_balanced = RandomForestClassifier(n_estimators=100, class_weight="balanced", random_state=42)
rf_balanced.fit(X_tr, y_tr)
y_pred_balanced = rf_balanced.predict(X_te)

print(f"\nAVEC class_weight='balanced' :")
print(f"  Accuracy : {accuracy_score(y_te, y_pred_balanced):.4f}")
print(f"  Recall   : {recall_score(y_te, y_pred_balanced):.4f} <- Amélioration !")
print(f"  F1       : {f1_score(y_te, y_pred_balanced):.4f}")

# Avec SMOTE (si disponible)
try:
    from imblearn.over_sampling import SMOTE
    smote = SMOTE(random_state=42)
    X_sm, y_sm = smote.fit_resample(X_tr, y_tr)
    rf_smote = RandomForestClassifier(n_estimators=100, random_state=42)
    rf_smote.fit(X_sm, y_sm)
    y_pred_smote = rf_smote.predict(X_te)
    print(f"\nAVEC SMOTE :")
    print(f"  Accuracy : {accuracy_score(y_te, y_pred_smote):.4f}")
    print(f"  Recall   : {recall_score(y_te, y_pred_smote):.4f}")
    print(f"  F1       : {f1_score(y_te, y_pred_smote):.4f}")
except ImportError:
    print("\nSMOTE : pip install imbalanced-learn")


# ============================================================================
# [COURS] EXERCICE PRATIQUE 5 : PIPELINE ML COMPLET
# ============================================================================

"""
OBJECTIF : Construire un système de ML end-to-end robuste
CONTEXTE : Prédire si un client va churner (quitter l'abonnement)
"""

print("\n" + "="*70)
print("[COURS] EXERCICE : PRÉDICTION DU CHURN CLIENT")
print("="*70)

np.random.seed(42)
n_clients = 2000

# Simuler données clients
df_churn = pd.DataFrame({
    "ancienneté_mois": np.random.exponential(24, n_clients).round(0).astype(int),
    "dépenses_mensuelles": np.clip(np.random.normal(50, 20, n_clients), 5, 200).round(2),
    "nb_produits": np.random.choice([1, 2, 3, 4], n_clients, p=[0.4, 0.3, 0.2, 0.1]),
    "nb_contacts_support": np.random.poisson(1.5, n_clients),
    "satisfaction": np.random.choice([1, 2, 3, 4, 5], n_clients, p=[0.1, 0.2, 0.3, 0.25, 0.15]),
    "canal": np.random.choice(["Web", "Mobile", "Telephone"], n_clients, p=[0.5, 0.35, 0.15]),
    "region": np.random.choice(["Nord", "Sud", "Est", "Ouest"], n_clients),
    "contrat": np.random.choice(["Mensuel", "Annuel", "Biennal"], n_clients, p=[0.5, 0.35, 0.15]),
    "promo": np.random.choice([0, 1], n_clients, p=[0.6, 0.4]),
})

# Churn influencé par les features
p_churn = (
    0.1
    + 0.15 * (df_churn["satisfaction"] <= 2).astype(float)
    - 0.05 * (df_churn["ancienneté_mois"] > 24).astype(float)
    + 0.10 * (df_churn["nb_contacts_support"] > 3).astype(float)
    - 0.08 * (df_churn["contrat"] == "Annuel").astype(float)
    - 0.12 * (df_churn["contrat"] == "Biennal").astype(float)
    + np.random.normal(0, 0.05, n_clients)  # Bruit
)
df_churn["churn"] = (np.clip(p_churn, 0.02, 0.9) > np.random.rand(n_clients)).astype(int)

print(f"\nDataset : {df_churn.shape}")
print(f"Taux de churn : {df_churn['churn'].mean()*100:.1f}%")

# ── SPLIT ──
features = ["ancienneté_mois", "dépenses_mensuelles", "nb_produits",
            "nb_contacts_support", "satisfaction", "canal", "region",
            "contrat", "promo"]
features_num_churn = ["ancienneté_mois", "dépenses_mensuelles", "nb_produits",
                      "nb_contacts_support", "satisfaction", "promo"]
features_cat_churn = ["canal", "region", "contrat"]

X_churn = df_churn[features]
y_churn = df_churn["churn"]

X_tr_c, X_te_c, y_tr_c, y_te_c = train_test_split(
    X_churn, y_churn, test_size=0.2, random_state=42, stratify=y_churn
)

# ── PIPELINE ──
prep_churn = ColumnTransformer([
    ("num", Pipeline([
        ("imp", SimpleImputer(strategy="median")),
        ("scl", StandardScaler())
    ]), features_num_churn),
    ("cat", Pipeline([
        ("imp", SimpleImputer(strategy="most_frequent")),
        ("enc", OneHotEncoder(drop="first", handle_unknown="ignore"))
    ]), features_cat_churn)
])

# ── MODÈLES ──
modeles_churn = {
    "Logistic Regression": LogisticRegression(max_iter=1000, class_weight="balanced", random_state=42),
    "Random Forest": RandomForestClassifier(100, class_weight="balanced", random_state=42, n_jobs=-1),
    "Gradient Boosting": GradientBoostingClassifier(100, random_state=42),
}

print("\n── COMPARAISON DES MODÈLES ──")
resultats_churn = []
cv_churn = StratifiedKFold(5, shuffle=True, random_state=42)

for nom, modele in modeles_churn.items():
    pipe = Pipeline([("prep", prep_churn), ("clf", modele)])
    auc_scores = cross_val_score(pipe, X_tr_c, y_tr_c, cv=cv_churn, scoring="roc_auc")
    f1_scores = cross_val_score(pipe, X_tr_c, y_tr_c, cv=cv_churn, scoring="f1")
    recall_scores = cross_val_score(pipe, X_tr_c, y_tr_c, cv=cv_churn, scoring="recall")
    resultats_churn.append({
        "Modèle": nom,
        "AUC-ROC": auc_scores.mean(),
        "F1": f1_scores.mean(),
        "Recall": recall_scores.mean()
    })
    print(f"  {nom:25} | AUC={auc_scores.mean():.3f} | F1={f1_scores.mean():.3f} | Recall={recall_scores.mean():.3f}")

# ── MEILLEUR MODÈLE - ÉVALUATION FINALE ──
meilleur = Pipeline([
    ("prep", prep_churn),
    ("clf", RandomForestClassifier(
        n_estimators=200, class_weight="balanced",
        max_depth=8, random_state=42, n_jobs=-1
    ))
])
meilleur.fit(X_tr_c, y_tr_c)
y_pred_c = meilleur.predict(X_te_c)
y_proba_c = meilleur.predict_proba(X_te_c)[:, 1]

print(f"\n── RÉSULTATS FINAUX SUR TEST ──")
print(f"AUC-ROC  : {roc_auc_score(y_te_c, y_proba_c):.4f}")
print(f"F1-Score : {f1_score(y_te_c, y_pred_c):.4f}")
print(f"Recall   : {recall_score(y_te_c, y_pred_c):.4f}")
print(f"\nRapport de classification :")
print(classification_report(y_te_c, y_pred_c, target_names=["Non-churn", "Churn"]))

# ── VISUALISATION FINALE ──
fig, axes = plt.subplots(1, 3, figsize=(18, 6))
fig.suptitle("Prédiction du Churn Client - Analyse Finale", fontsize=14, fontweight="bold")

# 1. Matrice de confusion
cm = confusion_matrix(y_te_c, y_pred_c)
sns.heatmap(cm, annot=True, fmt="d", cmap="Blues", ax=axes[0],
            xticklabels=["Non-churn", "Churn"], yticklabels=["Non-churn", "Churn"])
axes[0].set_title("Matrice de Confusion")
axes[0].set_ylabel("Réel")
axes[0].set_xlabel("Prédit")

# 2. Courbe ROC
fpr_c, tpr_c, _ = roc_curve(y_te_c, y_proba_c)
auc_c = roc_auc_score(y_te_c, y_proba_c)
axes[1].plot(fpr_c, tpr_c, lw=2.5, color="steelblue", label=f"ROC (AUC={auc_c:.3f})")
axes[1].plot([0, 1], [0, 1], "k--", label="Aléatoire")
axes[1].fill_between(fpr_c, tpr_c, alpha=0.1, color="steelblue")
axes[1].set_xlabel("Taux Faux Positifs")
axes[1].set_ylabel("Taux Vrais Positifs")
axes[1].set_title("Courbe ROC")
axes[1].legend()
axes[1].grid(True, alpha=0.3)

# 3. Distribution des probabilités
axes[2].hist(y_proba_c[y_te_c == 0], bins=30, alpha=0.6, color="steelblue",
             label="Non-churn", density=True)
axes[2].hist(y_proba_c[y_te_c == 1], bins=30, alpha=0.6, color="coral",
             label="Churn", density=True)
axes[2].axvline(x=0.5, color="black", ls="--", lw=2, label="Seuil 0.5")
axes[2].set_xlabel("Probabilité de churn prédite")
axes[2].set_ylabel("Densité")
axes[2].set_title("Distribution des probabilités")
axes[2].legend()
axes[2].grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig("ch20_churn_analyse.png", dpi=150, bbox_inches="tight")
plt.show()

print("\n[OK] Exercice terminé ! Modèle de churn entraîné et évalué.")


# ============================================================================
# [DOCS] RÉCAPITULATIF PARTIE 5
# ============================================================================

"""
[BRAVO] FÉLICITATIONS ! PARTIE 5 TERMINÉE !

VOUS MAÎTRISEZ MAINTENANT :

Chapitre 15 : Introduction au ML
[OK] Types de ML (supervisé, non-supervisé, renforcement)
[OK] Vocabulaire essentiel (features, labels, overfitting)
[OK] Split train/test, validation croisée
[OK] Compromis biais-variance

Chapitre 16 : Scikit-learn
[OK] API unifiée (fit/predict/transform)
[OK] Pipelines complets
[OK] StratifiedKFold cross-validation
[OK] ColumnTransformer

Chapitre 17 : Classification
[OK] Régression logistique
[OK] Arbres de décision
[OK] Random Forest (bagging)
[OK] Gradient Boosting (boosting)
[OK] SVM, KNN, Naive Bayes
[OK] Métriques : Accuracy, Precision, Recall, F1, AUC-ROC
[OK] Matrice de confusion, courbe ROC

Chapitre 18 : Régression
[OK] Régression linéaire, Ridge, Lasso, ElasticNet
[OK] Régression par arbres et ensembles
[OK] Métriques : MAE, MSE, RMSE, R²
[OK] Courbes d'apprentissage

Chapitre 19 : Clustering & Réduction
[OK] K-Means (Elbow + Silhouette)
[OK] DBSCAN (détection d'anomalies)
[OK] PCA (réduction de dimensions)
[OK] t-SNE (visualisation)

Chapitre 20 : Optimisation
[OK] GridSearchCV, RandomizedSearchCV
[OK] Données déséquilibrées (class_weight, SMOTE)
[OK] Pipeline ML end-to-end robuste


[CLE] CHECKLIST ML PROFESSIONNELLE

[WHITE_SQUARE] 1. Exploration des données (EDA)
[WHITE_SQUARE] 2. Nettoyage et feature engineering
[WHITE_SQUARE] 3. Split stratifié (stratify=y)
[WHITE_SQUARE] 4. Pipeline (pas de data leakage !)
[WHITE_SQUARE] 5. Validation croisée sur train
[WHITE_SQUARE] 6. Comparer plusieurs modèles
[WHITE_SQUARE] 7. Optimiser les hyperparamètres
[WHITE_SQUARE] 8. Évaluer sur test (une seule fois !)
[WHITE_SQUARE] 9. Analyser les erreurs (cas limites)
[WHITE_SQUARE] 10. Documenter et déployer


-> PROCHAINE ÉTAPE : PARTIE 6 - PROJETS ET PRODUCTION

Vous allez apprendre :
- Pipelines de données automatisés
- SQL avec Python
- APIs et Web Scraping
- Rapports automatisés
- Déploiement de modèles ML
- Best Practices et Projet Final

Prêt pour la production ? [RAPIDE]
"""

