"""
Module: Gradient Boosting
Categorie: Supervised Regression
Difficulte: Intermediaire

Genere depuis la plateforme ML Formation
"""

# Imports
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score, mean_squared_error, r2_score

# Charger le dataset
df = pd.read_csv('housing_simple.csv')

# Explorer les donnees
# Type: Code executable
# Fonction pour formater les nombres au style francais (espace comme separateur)
def fmt(n, decimals=0):
    n = float(n)  # Convertit les arrays numpy en scalaire
    return f"{n:,.{decimals}f}".replace(",", " ")

print("=" * 70)
print("   EXPLORATION DU DATASET IMMOBILIER")
print("   Pour l'entrainement du Gradient Boosting")
print("=" * 70)

# =================================================================
# 1. APERCU DES DONNEES
# =================================================================
print("\n" + "-" * 40)
print("1. APERCU DU DATASET")
print("-" * 40)
print("""
Nous utilisons le meme dataset immobilier que pour le Random Forest.
Le Gradient Boosting va apprendre a corriger iterativement ses erreurs.
""")
display(df.head(10), title="Dataset Immobilier")

# =================================================================
# 2. DISTRIBUTION DES PRIX
# =================================================================
print("\n" + "-" * 40)
print("2. DISTRIBUTION DU PRIX CIBLE")
print("-" * 40)

print(f"""
STATISTIQUES DU PRIX:

  Minimum:     {df['price'].min():>12,} EUR
  Maximum:     {df['price'].max():>12,} EUR
  Moyenne:     {fmt(df['price'].mean()):>12} EUR
  Mediane:     {fmt(df['price'].median()):>12} EUR
  Ecart-type:  {fmt(df['price'].std()):>12} EUR

INTERPRETATION:
- Le Gradient Boosting va commencer par predire la moyenne (~{fmt(df['price'].mean())} EUR)
- Puis, chaque arbre va corriger les erreurs residuelles
- Apres N iterations, l'erreur devrait diminuer significativement
""")

# =================================================================
# 3. CORRELATIONS AVEC LE PRIX
# =================================================================
print("\n" + "-" * 40)
print("3. CORRELATIONS")
print("-" * 40)

corr_surface = df['surface'].corr(df['price'])
corr_rooms = df['nb_rooms'].corr(df['price'])

print(f"""
Correlation avec le prix:
  - Surface:   {corr_surface:.3f} ({'forte' if abs(corr_surface) > 0.7 else 'moderee'})
  - Nb_rooms:  {corr_rooms:.3f} ({'forte' if abs(corr_rooms) > 0.7 else 'moderee'})

Le Gradient Boosting peut capturer des relations plus complexes
que les correlations lineaires simples!
""")


# Entrainer un Gradient Boosting
# Type: Code executable
# Fonction pour formater les nombres au style francais (espace comme separateur)
def fmt(n, decimals=0):
    n = float(n)  # Convertit les arrays numpy en scalaire
    return f"{n:,.{decimals}f}".replace(",", " ")

print("=" * 70)
print("   ENTRAINEMENT DU GRADIENT BOOSTING")
print("   Construction sequentielle des arbres correcteurs")
print("=" * 70)

from sklearn.ensemble import GradientBoostingRegressor

# =================================================================
# 1. PREPARATION DES DONNEES
# =================================================================
print("\n" + "-" * 40)
print("1. PREPARATION DES DONNEES")
print("-" * 40)

X = df[['surface', 'nb_rooms']].values
y = df['price'].values
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

print(f"""
Division des donnees:
  - Entrainement: {len(X_train)} biens ({len(X_train)/len(X)*100:.0f}%)
  - Test: {len(X_test)} biens ({len(X_test)/len(X)*100:.0f}%)
""")

# =================================================================
# 2. CONFIGURATION DU MODELE
# =================================================================
print("\n" + "-" * 40)
print("2. CONFIGURATION DU GRADIENT BOOSTING")
print("-" * 40)

gb = GradientBoostingRegressor(
    n_estimators=100,      # Nombre d'arbres
    learning_rate=0.1,     # Taux d'apprentissage
    max_depth=3,           # Profondeur max par arbre
    random_state=42
)

print(f"""
HYPERPARAMETRES CHOISIS:

n_estimators = 100
  - Nous allons construire 100 arbres sequentiellement
  - Chaque arbre corrige les erreurs des precedents

learning_rate = 0.1
  - Coefficient de reduction de la contribution de chaque arbre
  - Plus petit = apprentissage plus lent mais plus stable
  - Evite l'overfitting en ne corrigeant pas trop vite

max_depth = 3
  - Arbres peu profonds (stumps)
  - Typique pour le boosting (vs profondeur elevee pour RF)
  - Chaque arbre capture une partie simple du pattern

POURQUOI DES ARBRES PEU PROFONDS?
En boosting, on veut des "learners faibles" qui font des erreurs.
La combinaison de nombreux learners faibles = un learner fort!
""")

# =================================================================
# 3. ENTRAINEMENT
# =================================================================
print("\n" + "-" * 40)
print("3. ENTRAINEMENT SEQUENTIEL")
print("-" * 40)

import time
start_time = time.time()
gb.fit(X_train, y_train)
training_time = time.time() - start_time

print(f"""
ENTRAINEMENT TERMINE!

Temps d'entrainement: {training_time:.2f} secondes

Ce qui s'est passe:
1. Arbre 1: predit la valeur initiale (moyenne des y)
2. Arbre 2: apprend sur les residus de l'arbre 1
3. Arbre 3: apprend sur les residus cumules
...
100. Arbre 100: corrige les derniers residus

STATISTIQUES:
  Nombre total d'arbres: {gb.n_estimators_}
  Learning rate utilise: {gb.learning_rate}
""")

# =================================================================
# 4. DEMONSTRATION DU PROCESSUS
# =================================================================
print("\n" + "-" * 40)
print("4. ILLUSTRATION: CORRECTION PROGRESSIVE")
print("-" * 40)

# Montrer les predictions a differentes etapes
sample_idx = 0
sample = X_test[sample_idx:sample_idx+1]
true_price = y_test[sample_idx]

print(f"Pour un bien: surface={sample[0,0]:.0f}m2, pieces={sample[0,1]:.0f}")
print(f"Prix reel: {fmt(true_price)} EUR\n")

stages = [1, 5, 20, 50, 100]
for n in stages:
    # Utiliser staged_predict pour obtenir la prediction a l'etape n
    preds = list(gb.staged_predict(sample))
    if n <= len(preds):
        pred = preds[n-1][0]
        error = true_price - pred
        print(f"  Apres {n:3d} arbres: {fmt(pred):>12} EUR | Erreur: {'+' if error >= 0 else ''}{fmt(error)} EUR")

print("""
Observez comment l'erreur diminue a mesure que des arbres sont ajoutes!
C'est le coeur du Gradient Boosting: correction iterative.
""")


# Evaluer et comparer les modeles
# Type: Code executable
# Fonction pour formater les nombres au style francais (espace comme separateur)
def fmt(n, decimals=0):
    n = float(n)  # Convertit les arrays numpy en scalaire
    return f"{n:,.{decimals}f}".replace(",", " ")

print("=" * 70)
print("   COMPARAISON DES MODELES DE REGRESSION")
print("   Gradient Boosting vs Random Forest vs Regression Lineaire")
print("=" * 70)

from sklearn.ensemble import GradientBoostingRegressor, RandomForestRegressor
from sklearn.linear_model import LinearRegression
import time

# =================================================================
# PREPARATION
# =================================================================
X = df[['surface', 'nb_rooms']].values
y = df['price'].values
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# =================================================================
# 1. ENTRAINEMENT DES MODELES
# =================================================================
print("\n" + "-" * 40)
print("1. ENTRAINEMENT DE 3 MODELES")
print("-" * 40)

models = {
    'Regression Lineaire': LinearRegression(),
    'Random Forest': RandomForestRegressor(n_estimators=100, max_depth=10, random_state=42),
    'Gradient Boosting': GradientBoostingRegressor(n_estimators=100, learning_rate=0.1, max_depth=3, random_state=42)
}

results = {}
for name, model in models.items():
    start = time.time()
    model.fit(X_train, y_train)
    train_time = time.time() - start

    y_pred = model.predict(X_test)
    r2 = r2_score(y_test, y_pred)
    rmse = np.sqrt(mean_squared_error(y_test, y_pred))
    mae = np.mean(np.abs(y_test - y_pred))

    results[name] = {
        'R2': r2,
        'RMSE': rmse,
        'MAE': mae,
        'Time': train_time
    }
    print(f"  {name}: entraine en {train_time:.3f}s")

# =================================================================
# 2. TABLEAU COMPARATIF
# =================================================================
print("\n" + "-" * 40)
print("2. TABLEAU COMPARATIF")
print("-" * 40)

print(f"\n{'Modele':<22} | {'R2':>8} | {'RMSE (EUR)':>12} | {'MAE (EUR)':>12}")
print("-" * 62)
for name, metrics in results.items():
    print(f"{name:<22} | {metrics['R2']:>8.4f} | {fmt(metrics['RMSE']):>11} | {fmt(metrics['MAE']):>11}")

# =================================================================
# 3. INTERPRETATION
# =================================================================
print("\n" + "-" * 40)
print("3. INTERPRETATION DES RESULTATS")
print("-" * 40)

best_model = max(results, key=lambda x: results[x]['R2'])
best_r2 = results[best_model]['R2']

print(f"""
MEILLEUR MODELE: {best_model}
  - R2 = {best_r2:.4f} ({best_r2*100:.1f}% de variance expliquee)

COMPARAISON:
""")

# Comparer les modeles
r2_lr = results['Regression Lineaire']['R2']
r2_rf = results['Random Forest']['R2']
r2_gb = results['Gradient Boosting']['R2']

if r2_gb > r2_rf:
    print(f"  - Gradient Boosting surpasse Random Forest de {(r2_gb-r2_rf)*100:.2f}%")
else:
    print(f"  - Random Forest surpasse Gradient Boosting de {(r2_rf-r2_gb)*100:.2f}%")

print(f"""
QUAND UTILISER CHAQUE MODELE?

Regression Lineaire (R2={r2_lr:.3f}):
  - Simple et interpretable
  - Ideal si relations lineaires
  - Rapide a entrainer

Random Forest (R2={r2_rf:.3f}):
  - Robuste et peu sensible aux hyperparametres
  - Parallelisable (rapide sur gros datasets)
  - Moins de risque d'overfitting

Gradient Boosting (R2={r2_gb:.3f}):
  - Souvent les meilleures performances
  - Necessite un tuning plus fin
  - Risque d'overfitting si mal configure
""")

# =================================================================
# 4. VISUALISATION
# =================================================================
print("\n" + "-" * 40)
print("4. COMPARAISON VISUELLE")
print("-" * 40)

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# R2 Score
names = list(results.keys())
r2_scores = [results[n]['R2'] for n in names]
colors = ['#9B7AC4', '#C09CF0', '#F7E64D']

axes[0].barh(names, r2_scores, color=colors)
axes[0].set_xlabel('R2 Score', fontsize=12)
axes[0].set_title('Comparaison des R2', fontsize=14, fontweight='bold')
axes[0].grid(True, alpha=0.3, axis='x')
for i, (name, r2) in enumerate(zip(names, r2_scores)):
    axes[0].text(r2 + 0.01, i, f'{r2:.4f}', va='center', fontweight='bold')

# RMSE
rmse_scores = [results[n]['RMSE'] for n in names]
axes[1].barh(names, rmse_scores, color=colors)
axes[1].set_xlabel('RMSE (EUR)', fontsize=12)
axes[1].set_title('Comparaison des RMSE', fontsize=14, fontweight='bold')
axes[1].grid(True, alpha=0.3, axis='x')
for i, (name, rmse) in enumerate(zip(names, rmse_scores)):
    axes[1].text(rmse + 500, i, f'{fmt(rmse)}', va='center', fontweight='bold')

plt.tight_layout()
plt.show()


# Visualiser l'evolution de l'erreur
# Type: Code executable
# Fonction pour formater les nombres au style francais (espace comme separateur)
def fmt(n, decimals=0):
    n = float(n)  # Convertit les arrays numpy en scalaire
    return f"{n:,.{decimals}f}".replace(",", " ")

print("=" * 70)
print("   EVOLUTION DE L'ERREUR PENDANT L'ENTRAINEMENT")
print("   Comprendre la convergence du Gradient Boosting")
print("=" * 70)

from sklearn.ensemble import GradientBoostingRegressor

# =================================================================
# ENTRAINEMENT
# =================================================================
X = df[['surface', 'nb_rooms']].values
y = df['price'].values
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

print("\n" + "-" * 40)
print("1. ENTRAINEMENT AVEC 200 ARBRES")
print("-" * 40)

gb = GradientBoostingRegressor(n_estimators=200, learning_rate=0.1, max_depth=3, random_state=42)
gb.fit(X_train, y_train)

print("Modele entraine! Calcul de l'erreur a chaque etape...")

# =================================================================
# CALCUL DES ERREURS A CHAQUE ETAPE
# =================================================================
print("\n" + "-" * 40)
print("2. CALCUL DES ERREURS")
print("-" * 40)

train_errors = []
test_errors = []

for y_pred_train in gb.staged_predict(X_train):
    train_errors.append(mean_squared_error(y_train, y_pred_train))

for y_pred_test in gb.staged_predict(X_test):
    test_errors.append(mean_squared_error(y_test, y_pred_test))

print(f"""
La methode staged_predict() permet de voir la prediction
apres chaque arbre. C'est crucial pour comprendre:
- Si le modele converge
- Quand il commence a overfitter

Erreur initiale (1 arbre):
  - Train: {fmt(train_errors[0])}
  - Test:  {fmt(test_errors[0])}

Erreur finale (200 arbres):
  - Train: {fmt(train_errors[-1])}
  - Test:  {fmt(test_errors[-1])}

Reduction de l'erreur:
  - Train: {(1 - train_errors[-1]/train_errors[0])*100:.1f}%
  - Test:  {(1 - test_errors[-1]/test_errors[0])*100:.1f}%
""")

# =================================================================
# VISUALISATION
# =================================================================
print("\n" + "-" * 40)
print("3. COURBE D'APPRENTISSAGE")
print("-" * 40)

plt.figure(figsize=(12, 6))
plt.plot(train_errors, label='Erreur Train', color='#9B7AC4', linewidth=2)
plt.plot(test_errors, label='Erreur Test', color='#F7E64D', linewidth=2)

# Trouver le minimum de l'erreur test
best_n = np.argmin(test_errors)
plt.axvline(best_n, color='#27ae60', linestyle='--', linewidth=2,
            label=f'Optimal: {best_n+1} arbres')
plt.scatter([best_n], [test_errors[best_n]], color='#27ae60', s=100, zorder=5)

plt.xlabel('Nombre d\'arbres', fontsize=12)
plt.ylabel('MSE', fontsize=12)
plt.title('Evolution de l\'erreur avec le nombre d\'arbres', fontsize=14, fontweight='bold')
plt.legend(fontsize=11)
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

# =================================================================
# INTERPRETATION
# =================================================================
print("\n" + "-" * 40)
print("4. INTERPRETATION DE LA COURBE")
print("-" * 40)

# Detecter l'overfitting
min_test_error = min(test_errors)
final_test_error = test_errors[-1]

print(f"""
ANALYSE DE LA CONVERGENCE:

Nombre optimal d'arbres: {best_n + 1}
  - Erreur test minimale: {fmt(min_test_error)}

Apres 200 arbres:
  - Erreur test: {fmt(final_test_error)}
""")

if final_test_error > min_test_error * 1.05:
    overfitting_pct = (final_test_error - min_test_error) / min_test_error * 100
    print(f"""
ATTENTION - OVERFITTING DETECTE!
  - L'erreur test a augmente de {overfitting_pct:.1f}% apres {best_n+1} arbres
  - L'erreur train continue de diminuer (le modele memorise)
  - L'erreur test remonte (le modele ne generalise plus)

SOLUTION: Utiliser early stopping ou reduire n_estimators a {best_n+1}
    """)
else:
    print(f"""
PAS D'OVERFITTING SIGNIFICATIF
  - L'erreur test reste stable apres convergence
  - Le modele generalise bien

NOTE: La ligne verte indique le point optimal
    """)


# Impact du Learning Rate
# Type: Code executable
# Fonction pour formater les nombres au style francais (espace comme separateur)
def fmt(n, decimals=0):
    n = float(n)  # Convertit les arrays numpy en scalaire
    return f"{n:,.{decimals}f}".replace(",", " ")

print("=" * 70)
print("   IMPACT DU LEARNING RATE")
print("   Trouver l'equilibre entre vitesse et precision")
print("=" * 70)

from sklearn.ensemble import GradientBoostingRegressor

# =================================================================
# PREPARATION
# =================================================================
X = df[['surface', 'nb_rooms']].values
y = df['price'].values
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# =================================================================
# 1. THEORIE DU LEARNING RATE
# =================================================================
print("\n" + "-" * 40)
print("1. QU'EST-CE QUE LE LEARNING RATE?")
print("-" * 40)
print("""
Le learning rate (taux d'apprentissage) controle la contribution
de chaque arbre a la prediction finale.

Formule: prediction_n = prediction_(n-1) + learning_rate * arbre_n

VALEURS TYPIQUES:
- 0.01: Tres lent, tres precis (necessite beaucoup d'arbres)
- 0.05: Lent mais stable
- 0.1:  Bon compromis (valeur par defaut)
- 0.2:  Rapide mais risque d'overfitting
- 0.5+: Dangereux, convergence instable

REGLE D'OR:
Plus le learning rate est petit, plus il faut d'arbres.
""")

# =================================================================
# 2. TEST DE DIFFERENTS LEARNING RATES
# =================================================================
print("\n" + "-" * 40)
print("2. COMPARAISON EXPERIMENTALE")
print("-" * 40)

learning_rates = [0.01, 0.05, 0.1, 0.2, 0.5]
results = []

print(f"\n{'Learning Rate':>15} | {'R2 Score':>10} | {'RMSE':>12} | {'Interpretation'}")
print("-" * 60)

for lr in learning_rates:
    gb = GradientBoostingRegressor(
        n_estimators=100,
        learning_rate=lr,
        max_depth=3,
        random_state=42
    )
    gb.fit(X_train, y_train)
    y_pred = gb.predict(X_test)
    r2 = r2_score(y_test, y_pred)
    rmse = np.sqrt(mean_squared_error(y_test, y_pred))
    results.append({'lr': lr, 'r2': r2, 'rmse': rmse})

    # Interpretation
    if lr <= 0.05:
        interp = "Lent mais stable"
    elif lr <= 0.15:
        interp = "Equilibre"
    else:
        interp = "Risque overfitting"

    print(f"{lr:>15.2f} | {r2:>10.4f} | {fmt(rmse):>11} | {interp}")

# =================================================================
# 3. ANALYSE
# =================================================================
print("\n" + "-" * 40)
print("3. ANALYSE DES RESULTATS")
print("-" * 40)

best_result = max(results, key=lambda x: x['r2'])
print(f"""
MEILLEUR LEARNING RATE: {best_result['lr']}
  - R2 Score: {best_result['r2']:.4f}
  - RMSE: {fmt(best_result['rmse'])} EUR

OBSERVATIONS:
""")

# Comparer le meilleur au plus petit
if best_result['lr'] > 0.05:
    print(f"  - Un LR plus eleve ({best_result['lr']}) fonctionne bien avec 100 arbres")
    print(f"  - Un LR de 0.01 aurait besoin de plus d'arbres pour converger")
else:
    print(f"  - Un LR petit ({best_result['lr']}) converge lentement mais stablement")

# =================================================================
# 4. VISUALISATION
# =================================================================
print("\n" + "-" * 40)
print("4. VISUALISATION")
print("-" * 40)

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

lrs = [r['lr'] for r in results]
r2s = [r['r2'] for r in results]
rmses = [r['rmse'] for r in results]

# R2 en fonction du LR
colors = ['#27ae60' if r == max(r2s) else '#9B7AC4' for r in r2s]
axes[0].bar([str(lr) for lr in lrs], r2s, color=colors)
axes[0].set_xlabel('Learning Rate', fontsize=12)
axes[0].set_ylabel('R2 Score', fontsize=12)
axes[0].set_title('R2 Score en fonction du Learning Rate', fontsize=14, fontweight='bold')
axes[0].grid(True, alpha=0.3, axis='y')

# Ajouter les valeurs
for i, (lr, r2) in enumerate(zip(lrs, r2s)):
    axes[0].text(i, r2 + 0.002, f'{r2:.4f}', ha='center', fontsize=10)

# RMSE en fonction du LR
colors = ['#27ae60' if r == min(rmses) else '#F7E64D' for r in rmses]
axes[1].bar([str(lr) for lr in lrs], rmses, color=colors)
axes[1].set_xlabel('Learning Rate', fontsize=12)
axes[1].set_ylabel('RMSE (EUR)', fontsize=12)
axes[1].set_title('RMSE en fonction du Learning Rate', fontsize=14, fontweight='bold')
axes[1].grid(True, alpha=0.3, axis='y')

plt.tight_layout()
plt.show()

# =================================================================
# CONCLUSION
# =================================================================
print("\n" + "=" * 70)
print("RECOMMANDATIONS PRATIQUES")
print("=" * 70)
print("""
1. COMMENCEZ par LR = 0.1 (valeur par defaut)

2. SI OVERFITTING: Reduisez le LR a 0.05 ou 0.01
   (et augmentez n_estimators)

3. SI UNDERFITTING: Augmentez le LR a 0.2
   (avec prudence)

4. REGLE PRATIQUE:
   - LR = 0.1, n_estimators = 100
   - LR = 0.05, n_estimators = 200
   - LR = 0.01, n_estimators = 500-1000
""")


# Exercice: Early Stopping
# Type: Exercice
# Exercice: Implementez l'early stopping pour eviter l'overfitting

# Fonction pour formater les nombres au style francais (espace comme separateur)
def fmt(n, decimals=0):
    n = float(n)  # Convertit les arrays numpy en scalaire
    return f"{n:,.{decimals}f}".replace(",", " ")

from sklearn.ensemble import GradientBoostingRegressor

# Preparez les donnees avec validation set
X = df[['surface', 'nb_rooms']].values
y = df['price'].values

# TODO: Divisez en 3 parties: train (60%), validation (20%), test (20%)
# Indice: Faites deux appels a train_test_split

# TODO: Entrainez avec beaucoup d'arbres (500)
# et suivez l'erreur sur le validation set

# TODO: Trouvez le nombre optimal d'arbres (avant que l'erreur remonte)


# SHAP pour Gradient Boosting
# Type: Code executable
# Fonction pour formater les nombres au style francais (espace comme separateur)
def fmt(n, decimals=0):
    n = float(n)  # Convertit les arrays numpy en scalaire
    return f"{n:,.{decimals}f}".replace(",", " ")

print("=" * 70)
print("   EXPLICABILITE AVEC SHAP")
print("   Comprendre les predictions du Gradient Boosting")
print("=" * 70)

from sklearn.ensemble import GradientBoostingRegressor

# =================================================================
# ENTRAINEMENT
# =================================================================
X = df[['surface', 'nb_rooms']].values
y = df['price'].values
feature_names = ['surface', 'nb_rooms']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

gb = GradientBoostingRegressor(n_estimators=100, learning_rate=0.1, max_depth=3, random_state=42)
gb.fit(X_train, y_train)

# =================================================================
# 1. CALCUL DES VALEURS SHAP
# =================================================================
print("\n" + "-" * 40)
print("1. CALCUL DES VALEURS SHAP")
print("-" * 40)
print("""
SHAP permet de decomposer chaque prediction en contributions.
Pour le Gradient Boosting, on utilise TreeExplainer.
""")

explainer = shap.TreeExplainer(gb)
shap_values = explainer.shap_values(X_test)

print(f"""
Valeurs SHAP calculees pour {len(X_test)} echantillons.

Formule: Prix = Base + SHAP(surface) + SHAP(nb_rooms)
Base = prix moyen d'entrainement = {fmt(explainer.expected_value)} EUR
""")

# =================================================================
# 2. VUE D'ENSEMBLE
# =================================================================
print("\n" + "-" * 40)
print("2. IMPACT GLOBAL DES FEATURES")
print("-" * 40)
print("""
Ce graphique montre comment chaque feature influence les predictions.
- Chaque point = un bien du test set
- Couleur = valeur de la feature (rouge = haute, bleu = basse)
- Position X = impact sur le prix (positif/negatif)
""")

plt.figure(figsize=(10, 5))
shap.summary_plot(shap_values, X_test, feature_names=feature_names, show=False)
plt.title('SHAP - Gradient Boosting', fontsize=14, fontweight='bold')
plt.tight_layout()
plt.show()

# =================================================================
# 3. EXPLICATION D'UNE PREDICTION
# =================================================================
print("\n" + "-" * 40)
print("3. EXPLICATION DETAILLEE D'UNE PREDICTION")
print("-" * 40)

idx = 0
sample = X_test[idx]
prediction = gb.predict([sample])[0]
base_value = explainer.expected_value

print(f"""
BIEN IMMOBILIER A EXPLIQUER:
  Surface: {sample[0]:.0f} m2
  Nombre de pieces: {sample[1]:.0f}

PREDICTION DU MODELE:
  Prix predit: {fmt(prediction)} EUR

DECOMPOSITION SHAP:
  Prix de base (moyenne): {fmt(base_value)} EUR
""")

for i, name in enumerate(feature_names):
    shap_val = shap_values[idx][i]
    direction = "augmente" if shap_val > 0 else "diminue"
    print(f"    + Impact de {name}: {'+' if shap_val >= 0 else ''}{fmt(shap_val)} EUR ({direction} le prix)")

total = base_value + sum(shap_values[idx])
print(f"""
  = Prix calcule: {fmt(total)} EUR

INTERPRETATION:
""")

surface_impact = shap_values[idx][0]
rooms_impact = shap_values[idx][1]

if surface_impact > 0:
    print(f"  - Ce bien a une surface ({sample[0]:.0f}m2) SUPERIEURE a la moyenne")
    print(f"    --> Cela augmente le prix de {fmt(abs(surface_impact))} EUR")
else:
    print(f"  - Ce bien a une surface ({sample[0]:.0f}m2) INFERIEURE a la moyenne")
    print(f"    --> Cela diminue le prix de {fmt(abs(surface_impact))} EUR")

# =================================================================
# 4. COMPARAISON AVEC RANDOM FOREST
# =================================================================
print("\n" + "-" * 40)
print("4. COMPARAISON AVEC RANDOM FOREST")
print("-" * 40)

from sklearn.ensemble import RandomForestRegressor

rf = RandomForestRegressor(n_estimators=100, max_depth=10, random_state=42)
rf.fit(X_train, y_train)

rf_explainer = shap.TreeExplainer(rf)
rf_shap_values = rf_explainer.shap_values(X_test)

print(f"""
IMPORTANCE MOYENNE DES FEATURES (valeur absolue SHAP):

                Gradient Boosting    Random Forest
-----------------------------------------------------------
Surface:        {np.mean(np.abs(shap_values[:, 0])):>12,.0f} EUR   {np.mean(np.abs(rf_shap_values[:, 0])):>12,.0f} EUR
Nb_rooms:       {np.mean(np.abs(shap_values[:, 1])):>12,.0f} EUR   {np.mean(np.abs(rf_shap_values[:, 1])):>12,.0f} EUR

Les deux modeles attribuent des importances similaires car
le dataset est simple et les relations sont lineaires.
""")

# =================================================================
# CONCLUSION
# =================================================================
print("\n" + "=" * 70)
print("SYNTHESE - GRADIENT BOOSTING EXPLIQUE")
print("=" * 70)
print(f"""
CE QUE NOUS AVONS APPRIS:

1. Le Gradient Boosting construit des arbres sequentiellement
   - Chaque arbre corrige les erreurs du precedent
   - La prediction finale = somme des contributions

2. Hyperparametres critiques:
   - n_estimators: plus = mieux (avec early stopping)
   - learning_rate: petit = stable mais lent
   - max_depth: arbres peu profonds (3-5)

3. SHAP permet d'expliquer chaque prediction:
   - Decomposition: Base + contributions des features
   - Utile pour justifier une estimation aupres d'un client

PROCHAINE ETAPE: Essayez XGBoost ou LightGBM pour des datasets plus grands!
""")

