"""
Module: Reseau de Neurones from Scratch
Categorie: Deep Learning
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('binary_classification.csv')

# Charger et visualiser les donnees
# Type: Code executable
print("=" * 70)
print("   EXPLORATION DES DONNEES DE CLASSIFICATION BINAIRE")
print("   Pour l'entrainement de notre reseau de neurones")
print("=" * 70)

# =================================================================
# 1. APERCU DES DONNEES
# =================================================================
print("\n" + "-" * 40)
print("1. APERCU DU DATASET")
print("-" * 40)
print("""
Ce dataset contient des exemples pour un probleme de classification binaire.
Chaque exemple a 2 features et appartient a une classe (0 ou 1).
Notre reseau de neurones devra apprendre a distinguer les deux classes.
""")
display(df.head(10), title="Premiers exemples du dataset")

# =================================================================
# 2. DIMENSIONS ET STRUCTURE
# =================================================================
print("\n" + "-" * 40)
print("2. DIMENSIONS ET STRUCTURE")
print("-" * 40)
n_samples, n_features = df.shape[0], 2
print(f"""
Nombre total d'exemples : {n_samples}
Nombre de features      : {n_features} (feature1, feature2)
Variable cible          : label (0 ou 1)

En termes de reseau de neurones:
- Couche d'entree  : {n_features} neurones (un par feature)
- Couche de sortie : 1 neurone (probabilite de classe 1)
""")

# =================================================================
# 3. DISTRIBUTION DES CLASSES
# =================================================================
print("\n" + "-" * 40)
print("3. EQUILIBRE DES CLASSES")
print("-" * 40)
class_counts = df['label'].value_counts().sort_index()
total = len(df)
print(f"""
Classe 0 : {class_counts[0]:3d} exemples ({class_counts[0]/total*100:.1f}%)
Classe 1 : {class_counts[1]:3d} exemples ({class_counts[1]/total*100:.1f}%)
""")

ratio = min(class_counts) / max(class_counts)
if ratio > 0.8:
    print("  [OK] Classes bien equilibrees!")
    print("       Le reseau ne sera pas biaise vers une classe.")
elif ratio > 0.5:
    print("  [ATTENTION] Leger desequilibre.")
    print("              Surveiller la precision par classe.")
else:
    print("  [ATTENTION] Desequilibre significatif!")
    print("              Considerer des techniques de reequilibrage.")

# =================================================================
# 4. STATISTIQUES DES FEATURES
# =================================================================
print("\n" + "-" * 40)
print("4. STATISTIQUES DES FEATURES")
print("-" * 40)
print("""
Avant d'entrainer un reseau de neurones, il est crucial de comprendre
l'echelle des donnees. Des features avec des echelles tres differentes
peuvent ralentir ou empecher la convergence.
""")
for col in ['feature1', 'feature2']:
    data = df[col]
    print(f"  {col}:")
    print(f"    Min = {data.min():.3f} | Max = {data.max():.3f}")
    print(f"    Moyenne = {data.mean():.3f} | Ecart-type = {data.std():.3f}")
    print()

# =================================================================
# 5. VISUALISATION 2D
# =================================================================
print("\n" + "-" * 40)
print("5. VISUALISATION DES CLASSES")
print("-" * 40)
print("""
Le graphique ci-dessous montre la repartition des deux classes.
Si les classes sont bien separees, le reseau apprendra facilement.
Si elles se chevauchent beaucoup, la tache sera plus difficile.
""")

plt.figure(figsize=(10, 6))
colors = ['#9B7AC4' if l == 0 else '#F7E64D' for l in df['label']]
plt.scatter(df['feature1'], df['feature2'], c=colors, alpha=0.6,
            edgecolors='black', s=60)
plt.xlabel('Feature 1', fontsize=12)
plt.ylabel('Feature 2', fontsize=12)
plt.title('Donnees de classification binaire\n(Violet=Classe 0, Jaune=Classe 1)',
          fontsize=14)
plt.grid(True, alpha=0.3)
plt.legend(handles=[
    plt.scatter([], [], c='#9B7AC4', edgecolors='black', label='Classe 0'),
    plt.scatter([], [], c='#F7E64D', edgecolors='black', label='Classe 1')
], loc='upper right')
plt.tight_layout()
plt.show()

# =================================================================
# CONCLUSION
# =================================================================
print("\n" + "=" * 70)
print("SYNTHESE POUR L'ENTRAINEMENT")
print("=" * 70)
print(f"""
- {n_samples} exemples disponibles pour l'apprentissage
- 2 features en entree --> 1 probabilite en sortie
- Classes equilibrees: {'OUI' if ratio > 0.8 else 'NON'}
- Les features ont des echelles similaires (normalisation recommandee)

Prochaine etape: Normaliser les donnees et diviser en train/test.
""")


# Implementer les fonctions d'activation
# Type: Code executable
print("=" * 70)
print("   FONCTIONS D'ACTIVATION: LE COEUR DE LA NON-LINEARITE")
print("=" * 70)

# =================================================================
# 1. DEFINITION DES FONCTIONS
# =================================================================
print("\n" + "-" * 40)
print("1. IMPLEMENTATION DES FONCTIONS")
print("-" * 40)

def sigmoid(z):
    """Fonction sigmoid: compresse les valeurs entre 0 et 1"""
    return 1 / (1 + np.exp(-np.clip(z, -500, 500)))

def sigmoid_derivative(z):
    """Derivee de sigmoid pour le backpropagation"""
    s = sigmoid(z)
    return s * (1 - s)

def relu(z):
    """Fonction ReLU: max(0, z)"""
    return np.maximum(0, z)

def relu_derivative(z):
    """Derivee de ReLU"""
    return (z > 0).astype(float)

print("""
Nous avons defini 4 fonctions essentielles:

SIGMOID σ(z) = 1/(1+e^(-z))
- Compresse toute valeur entre 0 et 1
- Utile en sortie pour obtenir une probabilite
- Probleme: gradient tres faible aux extremites

RELU relu(z) = max(0, z)
- Simple: garde les positifs, annule les negatifs
- Efficace pour les couches cachees
- Resout le probleme du "vanishing gradient"

DERIVEES (pour backpropagation):
- sigmoid'(z) = sigmoid(z) * (1 - sigmoid(z))
- relu'(z) = 1 si z > 0, sinon 0
""")

# =================================================================
# 2. DEMONSTRATION AVEC DES VALEURS
# =================================================================
print("\n" + "-" * 40)
print("2. EXEMPLES DE CALCULS")
print("-" * 40)

test_values = [-2, -1, 0, 1, 2, 5]
print("""
Voyons comment chaque fonction transforme differentes valeurs:
""")
print(f"{'z':>8} | {'sigmoid(z)':>12} | {'relu(z)':>10} | {'d_sigmoid':>12} | {'d_relu':>10}")
print("-" * 60)
for z in test_values:
    sig = sigmoid(z)
    rel = relu(z)
    sig_d = sigmoid_derivative(z)
    rel_d = relu_derivative(np.array([z]))[0]
    print(f"{z:>8} | {sig:>12.4f} | {rel:>10.1f} | {sig_d:>12.4f} | {rel_d:>10.1f}")

print("""
Observations importantes:
- Sigmoid: z=-2 donne 0.12, z=+2 donne 0.88 (compression!)
- ReLU: z negatif donne 0, z positif reste inchange
- Derivee sigmoid max a z=0 (0.25), decroit aux extremites
- Derivee ReLU: 0 ou 1 seulement (gradient constant ou nul)
""")

# =================================================================
# 3. VISUALISATION GRAPHIQUE
# =================================================================
print("\n" + "-" * 40)
print("3. VISUALISATION DES FONCTIONS")
print("-" * 40)
print("""
Les graphiques ci-dessous montrent les fonctions et leurs derivees.
Comprendre ces courbes est essentiel pour le backpropagation!
""")

z = np.linspace(-5, 5, 100)

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

# Sigmoid
axes[0].plot(z, sigmoid(z), 'b-', linewidth=2.5, label='sigmoid(z)')
axes[0].plot(z, sigmoid_derivative(z), 'r--', linewidth=2, label="sigmoid'(z)")
axes[0].axhline(y=0.5, color='gray', linestyle=':', alpha=0.5, label='y=0.5')
axes[0].axhline(y=0, color='k', linewidth=0.5)
axes[0].axhline(y=1, color='gray', linestyle=':', alpha=0.5, label='y=1')
axes[0].axvline(x=0, color='k', linewidth=0.5)
axes[0].fill_between(z, sigmoid_derivative(z), alpha=0.2, color='red')
axes[0].set_title('Fonction Sigmoid', fontsize=14, fontweight='bold')
axes[0].set_xlabel('z (pre-activation)')
axes[0].set_ylabel('Valeur')
axes[0].legend(loc='upper left')
axes[0].grid(True, alpha=0.3)
axes[0].set_ylim(-0.1, 1.1)

# ReLU
axes[1].plot(z, relu(z), 'b-', linewidth=2.5, label='relu(z)')
axes[1].plot(z, relu_derivative(z), 'r--', linewidth=2, label="relu'(z)")
axes[1].axhline(y=0, color='k', linewidth=0.5)
axes[1].axvline(x=0, color='k', linewidth=0.5)
axes[1].set_title('Fonction ReLU', fontsize=14, fontweight='bold')
axes[1].set_xlabel('z (pre-activation)')
axes[1].set_ylabel('Valeur')
axes[1].legend(loc='upper left')
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

# =================================================================
# INTERPRETATION PEDAGOGIQUE
# =================================================================
print("\n" + "-" * 40)
print("INTERPRETATION")
print("-" * 40)
print("""
SIGMOID:
- Zone centrale (|z| < 2): gradient significatif, apprentissage actif
- Zones extremes (|z| > 4): gradient quasi-nul ("saturation")
--> Probleme: les neurones peuvent "mourir" (arret d'apprentissage)

RELU:
- z > 0: gradient constant de 1, apprentissage stable
- z < 0: gradient nul, neurone inactif
--> Solution au vanishing gradient, mais "dying ReLU" possible

EN PRATIQUE:
- Couches cachees: ReLU (rapide, efficace)
- Sortie binaire: Sigmoid (probabilite entre 0 et 1)
- Sortie multi-classe: Softmax (non couvert ici)
""")


# Preparer les donnees
# Type: Code executable
print("=" * 70)
print("   PREPARATION DES DONNEES POUR LE RESEAU DE NEURONES")
print("=" * 70)

# =================================================================
# 1. EXTRACTION DES FEATURES ET CIBLE
# =================================================================
print("\n" + "-" * 40)
print("1. EXTRACTION DES MATRICES")
print("-" * 40)

X = df[['feature1', 'feature2']].values
y = df['label'].values.reshape(-1, 1)

print(f"""
Nous convertissons le DataFrame en matrices NumPy:

X (features):
  - Forme: {X.shape} (n_samples x n_features)
  - Chaque ligne = un exemple
  - Chaque colonne = une feature

y (cible):
  - Forme: {y.shape} (n_samples x 1)
  - Reshape en colonne pour compatibilite matricielle
""")

# =================================================================
# 2. NORMALISATION (CRUCIALE!)
# =================================================================
print("\n" + "-" * 40)
print("2. NORMALISATION DES FEATURES")
print("-" * 40)

X_mean = X.mean(axis=0)
X_std = X.std(axis=0)
X_normalized = (X - X_mean) / X_std

print(f"""
POURQUOI NORMALISER?
Sans normalisation, les features avec de grandes valeurs dominent
les calculs de gradients, ralentissant ou empechant la convergence.

Formule: X_norm = (X - moyenne) / ecart-type

Statistiques utilisees:
  feature1: moyenne = {X_mean[0]:.4f}, std = {X_std[0]:.4f}
  feature2: moyenne = {X_mean[1]:.4f}, std = {X_std[1]:.4f}

Verification apres normalisation:
  feature1: moyenne = {X_normalized[:,0].mean():.6f} (≈0), std = {X_normalized[:,0].std():.4f} (≈1)
  feature2: moyenne = {X_normalized[:,1].mean():.6f} (≈0), std = {X_normalized[:,1].std():.4f} (≈1)

Les features sont maintenant centrees-reduites!
""")

# =================================================================
# 3. DIVISION TRAIN/TEST
# =================================================================
print("\n" + "-" * 40)
print("3. DIVISION TRAIN/TEST")
print("-" * 40)

np.random.seed(42)
indices = np.random.permutation(len(X))
split = int(0.8 * len(X))

X_train = X_normalized[indices[:split]]
X_test = X_normalized[indices[split:]]
y_train = y[indices[:split]]
y_test = y[indices[split:]]

print(f"""
Nous divisons les donnees en:
- 80% pour l'entrainement (apprentissage des poids)
- 20% pour le test (evaluation finale)

ENSEMBLE D'ENTRAINEMENT:
  X_train: {X_train.shape} ({X_train.shape[0]} exemples)
  y_train: {y_train.shape}
  Classe 0: {(y_train == 0).sum()} | Classe 1: {(y_train == 1).sum()}

ENSEMBLE DE TEST:
  X_test: {X_test.shape} ({X_test.shape[0]} exemples)
  y_test: {y_test.shape}
  Classe 0: {(y_test == 0).sum()} | Classe 1: {(y_test == 1).sum()}

La seed (42) garantit la reproductibilite du split.
""")

# =================================================================
# 4. APERCU DES DONNEES NORMALISEES
# =================================================================
print("\n" + "-" * 40)
print("4. APERCU DES DONNEES NORMALISEES")
print("-" * 40)
print("""
Voici les 5 premiers exemples d'entrainement apres normalisation:
""")
print(f"{'Exemple':>8} | {'feature1':>10} | {'feature2':>10} | {'label':>6}")
print("-" * 42)
for i in range(5):
    print(f"{i+1:>8} | {X_train[i,0]:>10.4f} | {X_train[i,1]:>10.4f} | {int(y_train[i,0]):>6}")

print("""
Notez que les valeurs sont maintenant autour de 0 (entre -3 et +3 typiquement).
C'est ideal pour les fonctions d'activation et la descente de gradient!
""")

# =================================================================
# CONCLUSION
# =================================================================
print("\n" + "=" * 70)
print("DONNEES PRETES POUR L'ENTRAINEMENT")
print("=" * 70)
print(f"""
Resume de la preparation:

1. Extraction: DataFrame --> matrices NumPy
2. Normalisation: centrage-reduction (moyenne=0, std=1)
3. Division: 80% train / 20% test

Prochaine etape: Definir l'architecture du reseau!
""")


# Initialiser le reseau
# Type: Code executable
print("=" * 70)
print("   INITIALISATION DE L'ARCHITECTURE DU RESEAU")
print("=" * 70)

# =================================================================
# FONCTIONS D'ACTIVATION
# =================================================================
def sigmoid(z):
    return 1 / (1 + np.exp(-np.clip(z, -500, 500)))

def sigmoid_derivative(z):
    s = sigmoid(z)
    return s * (1 - s)

def relu(z):
    return np.maximum(0, z)

def relu_derivative(z):
    return (z > 0).astype(float)

# =================================================================
# 1. DEFINITION DE LA CLASSE NEURALNETWORK
# =================================================================
print("\n" + "-" * 40)
print("1. ARCHITECTURE CHOISIE")
print("-" * 40)
print("""
Nous allons creer un reseau a 2 couches:

ENTREE (2 neurones)
    |
    v
[COUCHE CACHEE] (4 neurones) + ReLU
    |
    v
[COUCHE DE SORTIE] (1 neurone) + Sigmoid --> Probabilite [0,1]

Pourquoi cette architecture?
- 2 entrees car 2 features
- 4 neurones caches: assez pour apprendre des patterns complexes
- 1 sortie: classification binaire (probabilite de classe 1)
""")

class NeuralNetwork:
    def __init__(self, input_size, hidden_size, output_size):
        """
        Initialise les poids aleatoirement (initialisation Xavier)
        """
        np.random.seed(42)

        # Couche 1: entree -> cachee
        self.W1 = np.random.randn(input_size, hidden_size) * np.sqrt(2.0 / input_size)
        self.b1 = np.zeros((1, hidden_size))

        # Couche 2: cachee -> sortie
        self.W2 = np.random.randn(hidden_size, output_size) * np.sqrt(2.0 / hidden_size)
        self.b2 = np.zeros((1, output_size))

    def forward(self, X):
        """
        Forward pass: propagation des donnees a travers le reseau
        """
        # Couche cachee
        self.z1 = X @ self.W1 + self.b1      # Somme ponderee
        self.a1 = relu(self.z1)               # Activation ReLU

        # Couche de sortie
        self.z2 = self.a1 @ self.W2 + self.b2  # Somme ponderee
        self.a2 = sigmoid(self.z2)             # Activation Sigmoid

        return self.a2

# =================================================================
# 2. CREATION DU RESEAU
# =================================================================
print("\n" + "-" * 40)
print("2. CREATION ET ANALYSE DU RESEAU")
print("-" * 40)

nn = NeuralNetwork(input_size=2, hidden_size=4, output_size=1)

print(f"""
Reseau cree avec succes!

COUCHE D'ENTREE:
  - {nn.W1.shape[0]} neurones (= nombre de features)

COUCHE CACHEE:
  - {nn.W1.shape[1]} neurones
  - Activation: ReLU

COUCHE DE SORTIE:
  - {nn.W2.shape[1]} neurone
  - Activation: Sigmoid (pour probabilite)
""")

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

total_params = nn.W1.size + nn.b1.size + nn.W2.size + nn.b2.size

print(f"""
MATRICE DES POIDS W1 (entree -> cachee):
  Forme: {nn.W1.shape} (2 entrees x 4 neurones caches)
  Nombre de poids: {nn.W1.size}
  Valeurs: entre {nn.W1.min():.4f} et {nn.W1.max():.4f}

BIAIS b1:
  Forme: {nn.b1.shape}
  Initialises a: {nn.b1[0,0]} (zeros)

MATRICE DES POIDS W2 (cachee -> sortie):
  Forme: {nn.W2.shape} (4 neurones caches x 1 sortie)
  Nombre de poids: {nn.W2.size}
  Valeurs: entre {nn.W2.min():.4f} et {nn.W2.max():.4f}

BIAIS b2:
  Forme: {nn.b2.shape}
  Initialise a: {nn.b2[0,0]} (zero)

TOTAL DES PARAMETRES: {total_params}
- Ce sont les {total_params} valeurs que le reseau va ajuster pendant l'entrainement!
""")

# =================================================================
# 4. TEST DU FORWARD PASS
# =================================================================
print("\n" + "-" * 40)
print("4. TEST DU FORWARD PASS (avant entrainement)")
print("-" * 40)

# Preparer des donnees de test
X = df[['feature1', 'feature2']].values
X_mean, X_std = X.mean(axis=0), X.std(axis=0)
X_normalized = (X - X_mean) / X_std
test_sample = X_normalized[:3]

predictions = nn.forward(test_sample)

print("""
Testons le reseau sur 3 exemples (avant entrainement):
""")
print(f"{'Exemple':>8} | {'feature1':>10} | {'feature2':>10} | {'Prediction':>12} | {'Interpretation':>20}")
print("-" * 70)
for i in range(3):
    pred = predictions[i, 0]
    interp = "Classe 1 probable" if pred > 0.5 else "Classe 0 probable"
    print(f"{i+1:>8} | {test_sample[i,0]:>10.4f} | {test_sample[i,1]:>10.4f} | {pred:>12.4f} | {interp:>20}")

print("""
ATTENTION: Ces predictions sont ALEATOIRES!
Les poids n'ont pas encore ete entraines.
C'est comme demander a un enfant de resoudre une equation avant de lui apprendre.

Prochaine etape: Implementer l'entrainement (forward + backward + update)
""")


# Implementer l'entrainement complet
# Type: Code executable
print("=" * 70)
print("   ENTRAINEMENT COMPLET DU RESEAU DE NEURONES")
print("=" * 70)

# =================================================================
# FONCTIONS D'ACTIVATION
# =================================================================
def sigmoid(z):
    return 1 / (1 + np.exp(-np.clip(z, -500, 500)))

def sigmoid_derivative(z):
    s = sigmoid(z)
    return s * (1 - s)

def relu(z):
    return np.maximum(0, z)

def relu_derivative(z):
    return (z > 0).astype(float)

# =================================================================
# PREPARATION DES DONNEES
# =================================================================
X = df[['feature1', 'feature2']].values
y = df['label'].values.reshape(-1, 1)
X_mean, X_std = X.mean(axis=0), X.std(axis=0)
X_normalized = (X - X_mean) / X_std

np.random.seed(42)
indices = np.random.permutation(len(X))
split = int(0.8 * len(X))
X_train, X_test = X_normalized[indices[:split]], X_normalized[indices[split:]]
y_train, y_test = y[indices[:split]], y[indices[split:]]

# =================================================================
# 1. CLASSE COMPLETE AVEC BACKPROPAGATION
# =================================================================
print("\n" + "-" * 40)
print("1. DEFINITION DU RESEAU AVEC BACKPROPAGATION")
print("-" * 40)
print("""
Le reseau implemente maintenant 4 methodes essentielles:
- forward(): propagation avant
- backward(): calcul des gradients et mise a jour
- compute_loss(): calcul de l'erreur (Binary Cross-Entropy)
- train(): boucle d'entrainement complete
""")

class NeuralNetwork:
    def __init__(self, input_size, hidden_size, output_size):
        np.random.seed(42)
        self.W1 = np.random.randn(input_size, hidden_size) * np.sqrt(2.0 / input_size)
        self.b1 = np.zeros((1, hidden_size))
        self.W2 = np.random.randn(hidden_size, output_size) * np.sqrt(2.0 / hidden_size)
        self.b2 = np.zeros((1, output_size))
        self.losses = []

    def forward(self, X):
        self.z1 = X @ self.W1 + self.b1
        self.a1 = relu(self.z1)
        self.z2 = self.a1 @ self.W2 + self.b2
        self.a2 = sigmoid(self.z2)
        return self.a2

    def backward(self, X, y, learning_rate=0.1):
        """Backpropagation: calcul des gradients et mise a jour des poids"""
        m = X.shape[0]

        # Gradient de la couche de sortie
        dz2 = self.a2 - y  # Derivee de la cross-entropy avec sigmoid
        dW2 = (1/m) * self.a1.T @ dz2
        db2 = (1/m) * np.sum(dz2, axis=0, keepdims=True)

        # Gradient de la couche cachee
        da1 = dz2 @ self.W2.T
        dz1 = da1 * relu_derivative(self.z1)
        dW1 = (1/m) * X.T @ dz1
        db1 = (1/m) * np.sum(dz1, axis=0, keepdims=True)

        # Mise a jour des poids (descente de gradient)
        self.W2 -= learning_rate * dW2
        self.b2 -= learning_rate * db2
        self.W1 -= learning_rate * dW1
        self.b1 -= learning_rate * db1

    def compute_loss(self, y_true, y_pred):
        """Binary cross-entropy loss"""
        epsilon = 1e-15
        y_pred = np.clip(y_pred, epsilon, 1 - epsilon)
        return -np.mean(y_true * np.log(y_pred) + (1 - y_true) * np.log(1 - y_pred))

    def train(self, X, y, epochs=100, learning_rate=0.5):
        """Entrainement complet"""
        for epoch in range(epochs):
            # Forward pass
            y_pred = self.forward(X)

            # Calculer la loss
            loss = self.compute_loss(y, y_pred)
            self.losses.append(loss)

            # Backward pass
            self.backward(X, y, learning_rate)

            if epoch % 20 == 0:
                acc = np.mean((y_pred > 0.5) == y)
                print(f"Epoch {epoch:3d} | Loss: {loss:.4f} | Accuracy: {acc:.2%}")

    def predict(self, X):
        return (self.forward(X) > 0.5).astype(int)

# =================================================================
# 2. LANCEMENT DE L'ENTRAINEMENT
# =================================================================
print("\n" + "-" * 40)
print("2. LANCEMENT DE L'ENTRAINEMENT")
print("-" * 40)
print("""
Parametres d'entrainement:
- Epochs: 100 (iterations sur tout le dataset)
- Learning rate: 0.5 (pas de la descente de gradient)
- Architecture: 2 -> 8 -> 1

Demarrage...
""")

nn = NeuralNetwork(input_size=2, hidden_size=8, output_size=1)
nn.train(X_train, y_train, epochs=100, learning_rate=0.5)

# =================================================================
# 3. INTERPRETATION DU PROCESSUS
# =================================================================
print("\n" + "-" * 40)
print("3. INTERPRETATION DU PROCESSUS D'APPRENTISSAGE")
print("-" * 40)

initial_loss = nn.losses[0]
final_loss = nn.losses[-1]
loss_reduction = (1 - final_loss/initial_loss) * 100

print(f"""
ANALYSE DE LA CONVERGENCE:

Loss initiale (epoch 0):  {initial_loss:.4f}
Loss finale (epoch 99):   {final_loss:.4f}
Reduction de la loss:     {loss_reduction:.1f}%

QUE S'EST-IL PASSE A CHAQUE EPOCH?

1. FORWARD: Les donnees traversent le reseau
   X -> W1,b1 -> ReLU -> W2,b2 -> Sigmoid -> predictions

2. LOSS: On mesure l'erreur entre predictions et verite
   Binary Cross-Entropy penalise les predictions trop confiantes et fausses

3. BACKWARD: On calcule les gradients (responsabilite de chaque poids)
   dL/dW = combien la loss changerait si on modifie W

4. UPDATE: On ajuste les poids dans la direction opposee au gradient
   W = W - learning_rate * gradient
""")

# =================================================================
# 4. GUIDE D'INTERPRETATION
# =================================================================
print("\n" + "-" * 40)
print("4. GUIDE D'INTERPRETATION")
print("-" * 40)
print("""
COMPRENDRE LA LOSS:

Loss = 0.69 : Le modele predit 50/50 (hasard complet)
Loss = 0.30 : Bonnes predictions en general
Loss = 0.10 : Predictions tres confiantes et justes
Loss = 0.01 : Quasi-parfait (attention au surapprentissage!)

COMPRENDRE L'ACCURACY:

50% : Hasard (classification binaire)
70% : Modele raisonnable
85% : Bon modele
95%+ : Excellent (ou surapprentissage?)

SIGNES D'UN BON ENTRAINEMENT:
- Loss decroit regulierement
- Accuracy augmente en parallele
- Pas de rebonds brutaux
""")


# Visualiser l'apprentissage
# Type: Code executable
print("=" * 70)
print("   VISUALISATION DE L'APPRENTISSAGE DU RESEAU")
print("=" * 70)

# =================================================================
# PREPARATION
# =================================================================
def sigmoid(z):
    return 1 / (1 + np.exp(-np.clip(z, -500, 500)))

def relu(z):
    return np.maximum(0, z)

def relu_derivative(z):
    return (z > 0).astype(float)

X = df[['feature1', 'feature2']].values
y = df['label'].values.reshape(-1, 1)
X_mean, X_std = X.mean(axis=0), X.std(axis=0)
X_normalized = (X - X_mean) / X_std

np.random.seed(42)
indices = np.random.permutation(len(X))
split = int(0.8 * len(X))
X_train, X_test = X_normalized[indices[:split]], X_normalized[indices[split:]]
y_train, y_test = y[indices[:split]], y[indices[split:]]

class NeuralNetwork:
    def __init__(self, input_size, hidden_size, output_size):
        np.random.seed(42)
        self.W1 = np.random.randn(input_size, hidden_size) * np.sqrt(2.0 / input_size)
        self.b1 = np.zeros((1, hidden_size))
        self.W2 = np.random.randn(hidden_size, output_size) * np.sqrt(2.0 / hidden_size)
        self.b2 = np.zeros((1, output_size))
        self.losses = []

    def forward(self, X):
        self.z1 = X @ self.W1 + self.b1
        self.a1 = relu(self.z1)
        self.z2 = self.a1 @ self.W2 + self.b2
        self.a2 = sigmoid(self.z2)
        return self.a2

    def backward(self, X, y, learning_rate=0.1):
        m = X.shape[0]
        dz2 = self.a2 - y
        dW2 = (1/m) * self.a1.T @ dz2
        db2 = (1/m) * np.sum(dz2, axis=0, keepdims=True)
        da1 = dz2 @ self.W2.T
        dz1 = da1 * relu_derivative(self.z1)
        dW1 = (1/m) * X.T @ dz1
        db1 = (1/m) * np.sum(dz1, axis=0, keepdims=True)
        self.W2 -= learning_rate * dW2
        self.b2 -= learning_rate * db2
        self.W1 -= learning_rate * dW1
        self.b1 -= learning_rate * db1

    def compute_loss(self, y_true, y_pred):
        epsilon = 1e-15
        y_pred = np.clip(y_pred, epsilon, 1 - epsilon)
        return -np.mean(y_true * np.log(y_pred) + (1 - y_true) * np.log(1 - y_pred))

    def train(self, X, y, epochs=100, learning_rate=0.5):
        for epoch in range(epochs):
            y_pred = self.forward(X)
            loss = self.compute_loss(y, y_pred)
            self.losses.append(loss)
            self.backward(X, y, learning_rate)

    def predict(self, X):
        return (self.forward(X) > 0.5).astype(int)

# =================================================================
# ENTRAINEMENT
# =================================================================
print("\n" + "-" * 40)
print("1. ENTRAINEMENT (200 epochs)")
print("-" * 40)

nn = NeuralNetwork(input_size=2, hidden_size=8, output_size=1)
nn.train(X_train, y_train, epochs=200, learning_rate=0.5)

print(f"Entrainement termine!")
print(f"Loss initiale: {nn.losses[0]:.4f}")
print(f"Loss finale: {nn.losses[-1]:.4f}")

# =================================================================
# VISUALISATION
# =================================================================
print("\n" + "-" * 40)
print("2. VISUALISATION DE L'APPRENTISSAGE")
print("-" * 40)
print("""
Le graphique de gauche montre comment la loss diminue au fil des epochs.
Le graphique de droite montre la frontiere de decision apprise.
""")

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

# Courbe d'apprentissage
axes[0].plot(nn.losses, color='#9B7AC4', linewidth=2)
axes[0].axhline(y=0.693, color='gray', linestyle='--', alpha=0.7, label='Hasard (log(2))')
axes[0].set_xlabel('Epoch', fontsize=12)
axes[0].set_ylabel('Loss (Binary Cross-Entropy)', fontsize=12)
axes[0].set_title('Courbe d\'apprentissage', fontsize=14, fontweight='bold')
axes[0].grid(True, alpha=0.3)
axes[0].legend()

# Annoter les phases
if len(nn.losses) > 50:
    axes[0].annotate('Phase 1: Apprentissage rapide',
                    xy=(20, nn.losses[20]), fontsize=9,
                    xytext=(40, nn.losses[0] * 0.9),
                    arrowprops=dict(arrowstyle='->', color='gray'))
    axes[0].annotate('Phase 2: Convergence',
                    xy=(150, nn.losses[150]), fontsize=9,
                    xytext=(120, nn.losses[-1] * 1.5),
                    arrowprops=dict(arrowstyle='->', color='gray'))

# Frontiere de decision
x_min, x_max = X_normalized[:, 0].min() - 0.5, X_normalized[:, 0].max() + 0.5
y_min, y_max = X_normalized[:, 1].min() - 0.5, X_normalized[:, 1].max() + 0.5
xx, yy = np.meshgrid(np.linspace(x_min, x_max, 100), np.linspace(y_min, y_max, 100))
Z = nn.forward(np.c_[xx.ravel(), yy.ravel()])
Z = Z.reshape(xx.shape)

contour = axes[1].contourf(xx, yy, Z, levels=50, cmap='RdYlBu', alpha=0.6)
axes[1].contour(xx, yy, Z, levels=[0.5], colors='black', linewidths=2)
colors = ['#9B7AC4' if l == 0 else '#F7E64D' for l in y_test.flatten()]
axes[1].scatter(X_test[:, 0], X_test[:, 1], c=colors, edgecolors='black', s=60)
axes[1].set_xlabel('Feature 1 (normalise)', fontsize=12)
axes[1].set_ylabel('Feature 2 (normalise)', fontsize=12)
axes[1].set_title('Frontiere de decision apprise', fontsize=14, fontweight='bold')
plt.colorbar(contour, ax=axes[1], label='Probabilite classe 1')

plt.tight_layout()
plt.show()

# =================================================================
# EVALUATION SUR LE TEST SET
# =================================================================
print("\n" + "-" * 40)
print("3. EVALUATION SUR LE JEU DE TEST")
print("-" * 40)

y_pred_proba = nn.forward(X_test)
y_pred = (y_pred_proba > 0.5).astype(int)
accuracy = np.mean(y_pred == y_test)

# Matrice de confusion manuelle
tp = np.sum((y_pred == 1) & (y_test == 1))
tn = np.sum((y_pred == 0) & (y_test == 0))
fp = np.sum((y_pred == 1) & (y_test == 0))
fn = np.sum((y_pred == 0) & (y_test == 1))

print(f"""
PERFORMANCE SUR {len(y_test)} EXEMPLES DE TEST:

Accuracy globale: {accuracy:.2%}

MATRICE DE CONFUSION:
                    Predit 0    Predit 1
Vrai 0 (negatif)      {tn:3d}         {fp:3d}
Vrai 1 (positif)      {fn:3d}         {tp:3d}

INTERPRETATION:
- Vrais positifs (TP): {tp} - Bien detectes comme classe 1
- Vrais negatifs (TN): {tn} - Bien detectes comme classe 0
- Faux positifs (FP): {fp} - Erreur: predit 1 mais etait 0
- Faux negatifs (FN): {fn} - Erreur: predit 0 mais etait 1
""")

# Metriques supplementaires
precision = tp / (tp + fp) if (tp + fp) > 0 else 0
recall = tp / (tp + fn) if (tp + fn) > 0 else 0
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0

print(f"""
METRIQUES DETAILLEES:
- Precision: {precision:.2%} (parmi les predit positifs, combien sont vrais?)
- Recall:    {recall:.2%} (parmi les vrais positifs, combien sont detectes?)
- F1-Score:  {f1:.2%} (moyenne harmonique precision/recall)
""")

# =================================================================
# CONCLUSION
# =================================================================
print("\n" + "=" * 70)
print("CE QUE LE RESEAU A APPRIS")
print("=" * 70)
print(f"""
En partant de poids aleatoires, le reseau a:

1. REDUIT LA LOSS de {nn.losses[0]:.4f} a {nn.losses[-1]:.4f}
   --> Il fait moins d'erreurs de prediction

2. TROUVE UNE FRONTIERE DE DECISION
   --> La ligne noire separe les deux classes

3. ATTEINT {accuracy:.1%} D'ACCURACY
   --> Il generalise bien sur des donnees jamais vues

C'est la puissance du backpropagation: ajuster automatiquement
des milliers de parametres pour minimiser une fonction de cout!
""")


# Exercice: Ajouter une couche cachee
# Type: Exercice
# Exercice: Modifiez le reseau pour avoir 2 couches cachees
# Architecture: 2 -> 8 -> 4 -> 1

def sigmoid(z):
    return 1 / (1 + np.exp(-np.clip(z, -500, 500)))

def relu(z):
    return np.maximum(0, z)

def relu_derivative(z):
    return (z > 0).astype(float)

# Preparer les donnees
X = df[['feature1', 'feature2']].values
y = df['label'].values.reshape(-1, 1)
X_mean, X_std = X.mean(axis=0), X.std(axis=0)
X_normalized = (X - X_mean) / X_std

np.random.seed(42)
indices = np.random.permutation(len(X))
split = int(0.8 * len(X))
X_train, X_test = X_normalized[indices[:split]], X_normalized[indices[split:]]
y_train, y_test = y[indices[:split]], y[indices[split:]]

class DeepNeuralNetwork:
    def __init__(self):
        np.random.seed(42)
        # TODO: Initialisez 3 couches de poids
        # Couche 1: 2 -> 8
        self.W1 = np.random.randn(2, 8) * np.sqrt(2.0 / 2)
        self.b1 = np.zeros((1, 8))
        # Couche 2: 8 -> 4
        # self.W2 = ...
        # self.b2 = ...
        # Couche 3: 4 -> 1
        # self.W3 = ...
        # self.b3 = ...
        self.losses = []

    def forward(self, X):
        # TODO: Implementez le forward pass avec 3 couches
        # Couche 1
        self.z1 = X @ self.W1 + self.b1
        self.a1 = relu(self.z1)
        # Couche 2
        # ...
        # Couche 3
        # ...
        return self.a3

    # TODO: Implementez backward avec 3 couches


# Analyser les poids du reseau
# Type: Code executable
print("=" * 70)
print("   ANALYSE DES POIDS: QU'A APPRIS LE RESEAU?")
print("=" * 70)

# =================================================================
# PREPARATION
# =================================================================
def sigmoid(z):
    return 1 / (1 + np.exp(-np.clip(z, -500, 500)))

def relu(z):
    return np.maximum(0, z)

def relu_derivative(z):
    return (z > 0).astype(float)

X = df[['feature1', 'feature2']].values
y = df['label'].values.reshape(-1, 1)
feature_names = ['feature1', 'feature2']
X_mean, X_std = X.mean(axis=0), X.std(axis=0)
X_normalized = (X - X_mean) / X_std

np.random.seed(42)
indices = np.random.permutation(len(X))
split = int(0.8 * len(X))
X_train = X_normalized[indices[:split]]
y_train = y[indices[:split]]

class NeuralNetwork:
    def __init__(self, input_size, hidden_size, output_size):
        np.random.seed(42)
        self.W1 = np.random.randn(input_size, hidden_size) * np.sqrt(2.0 / input_size)
        self.b1 = np.zeros((1, hidden_size))
        self.W2 = np.random.randn(hidden_size, output_size) * np.sqrt(2.0 / hidden_size)
        self.b2 = np.zeros((1, output_size))

    def forward(self, X):
        self.z1 = X @ self.W1 + self.b1
        self.a1 = relu(self.z1)
        self.z2 = self.a1 @ self.W2 + self.b2
        self.a2 = sigmoid(self.z2)
        return self.a2

    def backward(self, X, y, lr=0.5):
        m = X.shape[0]
        dz2 = self.a2 - y
        dW2 = (1/m) * self.a1.T @ dz2
        db2 = (1/m) * np.sum(dz2, axis=0, keepdims=True)
        da1 = dz2 @ self.W2.T
        dz1 = da1 * relu_derivative(self.z1)
        dW1 = (1/m) * X.T @ dz1
        db1 = (1/m) * np.sum(dz1, axis=0, keepdims=True)
        self.W2 -= lr * dW2
        self.b2 -= lr * db2
        self.W1 -= lr * dW1
        self.b1 -= lr * db1

    def train(self, X, y, epochs=100):
        for _ in range(epochs):
            self.forward(X)
            self.backward(X, y)

nn = NeuralNetwork(2, 8, 1)
nn.train(X_train, y_train, epochs=100)

# =================================================================
# 1. ANALYSE DE LA MATRICE W1
# =================================================================
print("\n" + "-" * 40)
print("1. MATRICE DES POIDS W1 (ENTREE -> CACHEE)")
print("-" * 40)
print("""
La matrice W1 connecte les 2 features d'entree aux 8 neurones caches.
Chaque colonne represente les poids d'un neurone cache.
Des poids de grande magnitude = forte connexion.
""")

print(f"\nForme de W1: {nn.W1.shape}")
print("\nValeurs de W1:")
print(f"{'':>12}", end="")
for i in range(nn.W1.shape[1]):
    print(f"  Neurone {i+1:>2}", end="")
print()
for i, name in enumerate(feature_names):
    print(f"{name:>12}", end="")
    for j in range(nn.W1.shape[1]):
        val = nn.W1[i, j]
        print(f"    {val:>+6.3f}", end="")
    print()

# =================================================================
# 2. IMPORTANCE DES FEATURES
# =================================================================
print("\n" + "-" * 40)
print("2. IMPORTANCE RELATIVE DES FEATURES")
print("-" * 40)

# Calculer l'importance comme la somme des valeurs absolues des poids sortants
importance = np.abs(nn.W1).sum(axis=1)
importance_normalized = importance / importance.sum()

print("""
L'importance d'une feature se mesure par la somme des valeurs absolues
de ses poids vers la couche cachee. Plus le total est eleve, plus
la feature influence le reseau.
""")

print(f"\nImportance des features:")
for i, name in enumerate(feature_names):
    bar = "█" * int(importance_normalized[i] * 40)
    print(f"  {name:>10}: {importance_normalized[i]:>6.1%} {bar}")

dominant = feature_names[np.argmax(importance_normalized)]
print(f"\n  --> La feature '{dominant}' a le plus d'influence sur les predictions.")

# =================================================================
# 3. VISUALISATION DE LA MATRICE DES POIDS
# =================================================================
print("\n" + "-" * 40)
print("3. HEATMAP DE LA MATRICE DES POIDS")
print("-" * 40)
print("""
Cette visualisation montre comment chaque neurone cache "regarde"
les features d'entree. Les couleurs indiquent le signe et l'intensite.
""")

plt.figure(figsize=(12, 4))

plt.subplot(1, 2, 1)
im = plt.imshow(nn.W1.T, cmap='RdBu', aspect='auto', vmin=-2, vmax=2)
plt.colorbar(im, label='Valeur du poids')
plt.xlabel('Features d\'entree', fontsize=12)
plt.ylabel('Neurones caches', fontsize=12)
plt.xticks([0, 1], feature_names)
plt.yticks(range(8), [f'H{i+1}' for i in range(8)])
plt.title('Matrice des poids W1\n(Rouge=positif, Bleu=negatif)', fontsize=13)

# Ajouter les valeurs dans les cellules
for i in range(nn.W1.shape[0]):
    for j in range(nn.W1.shape[1]):
        text = plt.text(i, j, f'{nn.W1[i, j]:.2f}',
                       ha="center", va="center", color="black", fontsize=9)

plt.subplot(1, 2, 2)
colors = ['#9B7AC4', '#F7E64D']
plt.barh(feature_names, importance_normalized, color=colors)
plt.xlabel('Importance relative', fontsize=12)
plt.title('Importance des features', fontsize=13)
for i, v in enumerate(importance_normalized):
    plt.text(v + 0.01, i, f'{v:.1%}', va='center')

plt.tight_layout()
plt.show()

# =================================================================
# 4. INTERPRETATION
# =================================================================
print("\n" + "-" * 40)
print("4. INTERPRETATION DES POIDS")
print("-" * 40)

# Trouver les connexions les plus fortes
max_idx = np.unravel_index(np.abs(nn.W1).argmax(), nn.W1.shape)
max_val = nn.W1[max_idx]

print(f"""
ANALYSE DES CONNEXIONS:

Connexion la plus forte:
  {feature_names[max_idx[0]]} --> Neurone cache {max_idx[1]+1}
  Poids: {max_val:+.4f}
  Effet: {'EXCITATION' if max_val > 0 else 'INHIBITION'}

INTERPRETATION:
- Poids positif: quand la feature augmente, l'activation du neurone augmente
- Poids negatif: quand la feature augmente, l'activation du neurone diminue

Ces poids ont ete ajustes par backpropagation pour minimiser l'erreur
de classification. Ils encodent les "regles" apprises par le reseau.
""")


# Expliquer une prediction individuelle
# Type: Code executable
print("=" * 70)
print("   EXPLICABILITE: COMPRENDRE UNE PREDICTION INDIVIDUELLE")
print("=" * 70)

# =================================================================
# PREPARATION
# =================================================================
def sigmoid(z):
    return 1 / (1 + np.exp(-np.clip(z, -500, 500)))

def relu(z):
    return np.maximum(0, z)

def relu_derivative(z):
    return (z > 0).astype(float)

X = df[['feature1', 'feature2']].values
y = df['label'].values.reshape(-1, 1)
feature_names = ['feature1', 'feature2']
X_mean, X_std = X.mean(axis=0), X.std(axis=0)
X_normalized = (X - X_mean) / X_std

np.random.seed(42)
indices = np.random.permutation(len(X))
split = int(0.8 * len(X))
X_train, X_test = X_normalized[indices[:split]], X_normalized[indices[split:]]
y_train, y_test = y[indices[:split]], y[indices[split:]]

class NeuralNetworkWithGradient:
    def __init__(self, input_size, hidden_size, output_size):
        np.random.seed(42)
        self.W1 = np.random.randn(input_size, hidden_size) * np.sqrt(2.0 / input_size)
        self.b1 = np.zeros((1, hidden_size))
        self.W2 = np.random.randn(hidden_size, output_size) * np.sqrt(2.0 / hidden_size)
        self.b2 = np.zeros((1, output_size))

    def forward(self, X):
        self.X = X
        self.z1 = X @ self.W1 + self.b1
        self.a1 = relu(self.z1)
        self.z2 = self.a1 @ self.W2 + self.b2
        self.a2 = sigmoid(self.z2)
        return self.a2

    def backward(self, X, y, lr=0.5):
        m = X.shape[0]
        dz2 = self.a2 - y
        dW2 = (1/m) * self.a1.T @ dz2
        db2 = (1/m) * np.sum(dz2, axis=0, keepdims=True)
        da1 = dz2 @ self.W2.T
        dz1 = da1 * relu_derivative(self.z1)
        dW1 = (1/m) * X.T @ dz1
        db1 = (1/m) * np.sum(dz1, axis=0, keepdims=True)
        self.W2 -= lr * dW2
        self.b2 -= lr * db2
        self.W1 -= lr * dW1
        self.b1 -= lr * db1

    def train(self, X, y, epochs=100):
        for _ in range(epochs):
            self.forward(X)
            self.backward(X, y)

    def compute_gradient_attribution(self, x):
        """Calcule le gradient de la sortie par rapport a l'entree"""
        # Forward pass
        z1 = x @ self.W1 + self.b1
        a1 = relu(z1)
        z2 = a1 @ self.W2 + self.b2
        a2 = sigmoid(z2)

        # Backward pass pour calculer dOutput/dInput
        da2 = 1  # On veut d(output)/d(input)
        dz2 = da2 * a2 * (1 - a2)  # Derivee sigmoid
        da1 = dz2 @ self.W2.T
        dz1 = da1 * relu_derivative(z1)
        dx = dz1 @ self.W1.T

        return dx.flatten(), a2.flatten()[0]

nn = NeuralNetworkWithGradient(2, 8, 1)
nn.train(X_train, y_train, epochs=100)

# =================================================================
# 1. SELECTION D'UN EXEMPLE
# =================================================================
print("\n" + "-" * 40)
print("1. SELECTION D'UN EXEMPLE A EXPLIQUER")
print("-" * 40)

# Choisir quelques exemples interessants
examples_to_explain = [0, len(X_test)//2, len(X_test)-1]

for idx in examples_to_explain:
    sample = X_test[idx:idx+1]
    true_label = int(y_test[idx, 0])
    gradient, prediction = nn.compute_gradient_attribution(sample)

    print(f"\n{'='*50}")
    print(f"EXEMPLE {idx + 1}")
    print(f"{'='*50}")

    # Valeurs originales (denormalisees pour l'interpretation)
    original_values = sample.flatten() * X_std + X_mean

    print(f"""
Valeurs des features:
  feature1 = {original_values[0]:.3f} (normalise: {sample[0,0]:.3f})
  feature2 = {original_values[1]:.3f} (normalise: {sample[0,1]:.3f})

Vraie classe: {true_label}
Prediction du modele: {prediction:.1%} --> Classe {int(prediction > 0.5)}
Prediction correcte: {'OUI' if int(prediction > 0.5) == true_label else 'NON'}
    """)

    # =================================================================
    # 2. ATTRIBUTION PAR GRADIENT
    # =================================================================
    print("-" * 40)
    print("ATTRIBUTION PAR GRADIENT")
    print("-" * 40)
    print("""
Le gradient mesure la sensibilite de la sortie par rapport a chaque entree.
Un gradient positif signifie qu'augmenter cette feature augmente la probabilite.
Un gradient negatif signifie qu'augmenter cette feature diminue la probabilite.
    """)

    for i, name in enumerate(feature_names):
        direction = "pousse vers classe 1" if gradient[i] > 0 else "pousse vers classe 0"
        magnitude = abs(gradient[i])
        bar = "█" * int(min(magnitude * 100, 20))
        sign = "+" if gradient[i] > 0 else "-"
        print(f"  {name:>10}: gradient = {gradient[i]:>+.4f} | {direction}")

    # Interpretation finale
    dominant_feature = feature_names[np.argmax(np.abs(gradient))]
    dominant_direction = "classe 1" if gradient[np.argmax(np.abs(gradient))] > 0 else "classe 0"

    print(f"""
INTERPRETATION:
La feature '{dominant_feature}' a la plus grande influence sur cette prediction,
poussant le modele vers la {dominant_direction}.
    """)

# =================================================================
# 3. VISUALISATION COMPARATIVE
# =================================================================
print("\n" + "-" * 40)
print("3. VISUALISATION DES ATTRIBUTIONS")
print("-" * 40)

fig, axes = plt.subplots(1, 3, figsize=(15, 4))

for i, idx in enumerate(examples_to_explain):
    sample = X_test[idx:idx+1]
    true_label = int(y_test[idx, 0])
    gradient, prediction = nn.compute_gradient_attribution(sample)

    colors = ['#27ae60' if g > 0 else '#e74c3c' for g in gradient]
    axes[i].barh(feature_names, gradient, color=colors)
    axes[i].axvline(x=0, color='black', linestyle='-', linewidth=0.5)
    axes[i].set_xlabel('Gradient')
    axes[i].set_title(f'Exemple {idx+1}\nPred: {prediction:.1%} | Vrai: {true_label}')

    # Legende
    correct = int(prediction > 0.5) == true_label
    status = "Correct" if correct else "Erreur"
    color = '#27ae60' if correct else '#e74c3c'
    axes[i].text(0.95, 0.05, status, transform=axes[i].transAxes,
                fontsize=12, fontweight='bold', color=color,
                ha='right', va='bottom')

plt.suptitle('Attribution par gradient pour 3 exemples\n(Vert=pousse vers 1, Rouge=pousse vers 0)',
             fontsize=14, fontweight='bold')
plt.tight_layout()
plt.show()

# =================================================================
# CONCLUSION
# =================================================================
print("\n" + "=" * 70)
print("CE QUE NOUS AVONS APPRIS SUR L'EXPLICABILITE")
print("=" * 70)
print("""
TECHNIQUES UTILISEES:

1. ANALYSE DES POIDS (section precedente)
   - Vision globale de ce que le reseau a appris
   - Identifie les features importantes en general

2. ATTRIBUTION PAR GRADIENT (cette section)
   - Explication locale (pour un exemple specifique)
   - Montre pourquoi le modele a fait CETTE prediction

POURQUOI C'EST IMPORTANT:

- Confiance: Comprendre pourquoi un modele decide
- Debug: Identifier des comportements suspects
- Reglementaire: RGPD exige explicabilite pour decisions automatisees
- Communication: Expliquer a un non-technicien

LIMITES:
- Les gradients peuvent etre bruites pour des reseaux profonds
- Solutions avancees: SHAP, LIME, Integrated Gradients
""")

