"""
Module: K-Nearest Neighbors (KNN)
Categorie: Supervised Classification
Difficulte: Debutant

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('iris_simple.csv')

# Explorer le dataset Iris
# Type: Code executable
print("=" * 70)
print("      EXPLORATION DU DATASET IRIS POUR KNN")
print("=" * 70)

print("\n" + "=" * 70)
print("1. APERCU DU DATASET")
print("=" * 70)
print("""
Le dataset Iris est un classique du Machine Learning!
Il contient des mesures de 150 fleurs de 3 especes differentes.

KNN va apprendre a classifier une nouvelle fleur en regardant
les k fleurs les plus similaires (les plus proches).
""")
display(df.head(10), title="Dataset Iris")

print("\n" + "=" * 70)
print("2. DISTRIBUTION DES ESPECES")
print("=" * 70)
species_counts = df['species'].value_counts()
print("\nRepartition des especes:")
print("-" * 40)
for species, count in species_counts.items():
    pct = count / len(df) * 100
    bar = "█" * int(pct / 3)
    print(f"  {species:12}: {count:3d} ({pct:.0f}%)  {bar}")

print(f"""
Interpretation:
  → Dataset parfaitement equilibre (50 de chaque espece)
  → Ideal pour l'apprentissage supervise
  → Pas besoin de techniques de resampling
""")

print("\n" + "=" * 70)
print("3. STATISTIQUES DES CARACTERISTIQUES")
print("=" * 70)
print("""
KNN utilise les distances entre points, donc les ECHELLES
des features sont cruciales!
""")
display(df.describe().round(2), title="Statistiques descriptives")

print("\n" + "-" * 40)
print("ANALYSE DES ECHELLES (CRUCIAL POUR KNN):")
print("-" * 40)
features = ['sepal_length', 'sepal_width', 'petal_length', 'petal_width']
for feature in features:
    min_val = df[feature].min()
    max_val = df[feature].max()
    range_val = max_val - min_val
    print(f"  {feature:15}: [{min_val:.1f} - {max_val:.1f}], amplitude = {range_val:.1f}")

print(f"""
PROBLEME POTENTIEL:
  → sepal_length varie de 4.3 a 7.9 (amplitude 3.6)
  → petal_width varie de 0.1 a 2.5 (amplitude 2.4)

  Sans normalisation, sepal_length dominera le calcul de distance!
  La NORMALISATION est OBLIGATOIRE pour KNN.
""")


# Visualiser les donnees
# Type: Code executable
print("=" * 70)
print("    VISUALISATION DES DONNEES IRIS")
print("=" * 70)
print("""
Visualisons les donnees pour comprendre comment KNN va les separer.
Un bon algorithme KNN fonctionne mieux quand les classes
forment des groupes distincts dans l'espace des features.
""")

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

colors = {'setosa': '#9B7AC4', 'versicolor': '#C09CF0', 'virginica': '#F7E64D'}

# Petal length vs width
for species in df['species'].unique():
    mask = df['species'] == species
    axes[0].scatter(df[mask]['petal_length'], df[mask]['petal_width'],
                   c=colors[species], label=species, alpha=0.7, s=60, edgecolors='black')
axes[0].set_xlabel('Petal Length (cm)')
axes[0].set_ylabel('Petal Width (cm)')
axes[0].set_title('Petales: Longueur vs Largeur')
axes[0].legend()
axes[0].grid(True, alpha=0.3)

# Sepal length vs width
for species in df['species'].unique():
    mask = df['species'] == species
    axes[1].scatter(df[mask]['sepal_length'], df[mask]['sepal_width'],
                   c=colors[species], label=species, alpha=0.7, s=60, edgecolors='black')
axes[1].set_xlabel('Sepal Length (cm)')
axes[1].set_ylabel('Sepal Width (cm)')
axes[1].set_title('Sepales: Longueur vs Largeur')
axes[1].legend()
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

print("\n" + "=" * 70)
print("INTERPRETATION DES VISUALISATIONS")
print("=" * 70)
print("""
GRAPHIQUE DE GAUCHE (Petales):
  → Setosa (violet) est clairement separee des autres
  → Versicolor et Virginica se chevauchent un peu
  → Les petales sont de bons discriminants!

GRAPHIQUE DE DROITE (Sepales):
  → Plus de chevauchement entre les especes
  → Moins discriminant que les petales
  → Utile en combinaison avec d'autres features

IMPLICATIONS POUR KNN:
  → Un nouveau point dans la zone violette sera facilement classe Setosa
  → Dans la zone de chevauchement, le resultat depend de k
  → Plus k est grand, plus la decision est "lissee"
""")


# Entrainer un KNN
# Type: Code executable
from sklearn.neighbors import KNeighborsClassifier
from sklearn.preprocessing import StandardScaler

print("=" * 70)
print("         ENTRAINEMENT D'UN MODELE KNN")
print("=" * 70)

# Preparer les donnees
X = df[['sepal_length', 'sepal_width', 'petal_length', 'petal_width']].values
y = df['species'].values
feature_names = ['sepal_length', 'sepal_width', 'petal_length', 'petal_width']

print("\n" + "=" * 70)
print("1. PREPARATION DES DONNEES")
print("=" * 70)
print(f"""
Dimensions:
  • X (features): {X.shape[0]} echantillons x {X.shape[1]} features
  • y (labels): {y.shape[0]} etiquettes

Features utilisees:
""")
for i, name in enumerate(feature_names):
    print(f"    {i+1}. {name}")

print("\n" + "=" * 70)
print("2. NORMALISATION (ETAPE CRITIQUE!)")
print("=" * 70)
print("""
KNN calcule des DISTANCES. Si une feature a une echelle 10x plus
grande, elle dominera completement le calcul!

StandardScaler normalise: moyenne=0, ecart-type=1
""")

scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)

print("\n  Comparaison avant/apres normalisation:")
print("  " + "-" * 50)
print(f"  {'Feature':<15} | {'Avant':<20} | {'Apres':<20}")
print("  " + "-" * 50)
for i, name in enumerate(feature_names):
    before = f"[{X[:, i].min():.1f}, {X[:, i].max():.1f}]"
    after = f"[{X_scaled[:, i].min():.2f}, {X_scaled[:, i].max():.2f}]"
    print(f"  {name:<15} | {before:<20} | {after:<20}")

print("""
Resultat:
  → Toutes les features sont maintenant sur la meme echelle
  → Chaque feature contribue equitablement au calcul de distance
""")

print("\n" + "=" * 70)
print("3. DIVISION TRAIN/TEST")
print("=" * 70)
X_train, X_test, y_train, y_test = train_test_split(
    X_scaled, y, test_size=0.2, random_state=42, stratify=y
)
print(f"""
Division stratifiee 80/20:
  • Train: {len(X_train)} echantillons
  • Test:  {len(X_test)} echantillons

Stratification: garde les memes proportions de classes
""")

print("\n" + "=" * 70)
print("4. CREATION ET ENTRAINEMENT DU MODELE")
print("=" * 70)
print("""
Note importante: KNN est un algorithme "lazy" (paresseux)!

Il n'apprend PAS vraiment - il stocke juste les donnees
et fait les calculs au moment de la prediction.
""")

import time
start = time.time()

knn = KNeighborsClassifier(n_neighbors=5)
knn.fit(X_train, y_train)

fit_time = (time.time() - start) * 1000

print(f"\n  Modele configure en {fit_time:.2f} ms")
print(f"\n  Parametres du modele:")
print(f"    • Nombre de voisins (k): {knn.n_neighbors}")
print(f"    • Metrique de distance: {knn.metric}")
print(f"    • Methode de poids: {knn.weights}")
print(f"    • Algorithme de recherche: {knn.algorithm}")

print("\n" + "=" * 70)
print("RESUME")
print("=" * 70)
print(f"""
KNN pret a predire!

Pour chaque nouvelle fleur, il va:
  1. Calculer la distance avec les {len(X_train)} fleurs d'entrainement
  2. Selectionner les {knn.n_neighbors} plus proches
  3. Voter pour la classe majoritaire
""")


# Evaluer les performances
# Type: Code executable
from sklearn.neighbors import KNeighborsClassifier
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import confusion_matrix

print("=" * 70)
print("         EVALUATION DES PERFORMANCES KNN")
print("=" * 70)

# Preparation
X = df[['sepal_length', 'sepal_width', 'petal_length', 'petal_width']].values
y = df['species'].values
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
X_train, X_test, y_train, y_test = train_test_split(X_scaled, y, test_size=0.2, random_state=42, stratify=y)

# KNN
knn = KNeighborsClassifier(n_neighbors=5)
knn.fit(X_train, y_train)

# Predictions
y_pred = knn.predict(X_test)
acc = accuracy_score(y_test, y_pred)

print("\n" + "=" * 70)
print("1. ACCURACY GLOBALE")
print("=" * 70)
print(f"""
ACCURACY: {acc:.2%}

Interpretation:
  → {int(acc * len(y_test))} predictions correctes sur {len(y_test)}
  → Benchmarks pour Iris (3 classes):
    • > 95%: Excellent
    • 90-95%: Tres bon
    • 80-90%: Bon
    • < 80%: A ameliorer
""")

print("\n" + "=" * 70)
print("2. RAPPORT DE CLASSIFICATION PAR CLASSE")
print("=" * 70)
print(classification_report(y_test, y_pred))

print("-" * 40)
print("Guide d'interpretation:")
print("-" * 40)
print("""
Pour chaque espece:
  • Precision: parmi les predictions de cette espece, combien sont correctes?
  • Recall: parmi les vraies fleurs de cette espece, combien sont detectees?
  • F1-score: moyenne harmonique (equilibre precision/recall)
  • Support: nombre d'echantillons de cette classe dans le test set
""")

print("\n" + "=" * 70)
print("3. MATRICE DE CONFUSION")
print("=" * 70)
cm = confusion_matrix(y_test, y_pred, labels=knn.classes_)
print(f"""
                     Predictions
              Setosa  Versicolor  Virginica
Reel Setosa      {cm[0,0]:2d}         {cm[0,1]:2d}         {cm[0,2]:2d}
     Versicolor  {cm[1,0]:2d}         {cm[1,1]:2d}         {cm[1,2]:2d}
     Virginica   {cm[2,0]:2d}         {cm[2,1]:2d}         {cm[2,2]:2d}
""")

print("  Interpretation:")
print("    • Diagonale = predictions correctes")
print("    • Hors diagonale = erreurs (confusions)")

# Analyser les confusions
total_errors = len(y_test) - (cm[0,0] + cm[1,1] + cm[2,2])
if total_errors > 0:
    print(f"\n  Erreurs detectees: {total_errors}")
    for i, true_class in enumerate(knn.classes_):
        for j, pred_class in enumerate(knn.classes_):
            if i != j and cm[i, j] > 0:
                print(f"    → {cm[i,j]} {true_class} classee(s) comme {pred_class}")
else:
    print("\n  Aucune erreur! Classification parfaite.")

print("\n" + "=" * 70)
print("4. ANALYSE DE LA CONFIANCE")
print("=" * 70)
# KNN donne des probabilites basees sur les votes
y_proba = knn.predict_proba(X_test)
confidences = np.max(y_proba, axis=1)

print(f"""
Confiance des predictions (probabilite de la classe predite):
  • Moyenne: {confidences.mean():.1%}
  • Min:     {confidences.min():.1%}
  • Max:     {confidences.max():.1%}

Distribution de la confiance:
  • Tres confiant (>80%): {(confidences > 0.8).sum()} / {len(confidences)}
  • Confiant (60-80%):    {((confidences > 0.6) & (confidences <= 0.8)).sum()} / {len(confidences)}
  • Incertain (<60%):     {(confidences <= 0.6).sum()} / {len(confidences)}
""")

print("\n" + "=" * 70)
print("CONCLUSION")
print("=" * 70)
print(f"""
KNN avec k=5 obtient {acc:.2%} d'accuracy sur Iris.

Points cles:
  • Setosa est toujours bien classee (tres distincte)
  • Les erreurs sont souvent entre Versicolor et Virginica
  • k=5 est un bon point de depart, optimisons-le!
""")


# Trouver le meilleur k
# Type: Code executable
from sklearn.neighbors import KNeighborsClassifier
from sklearn.preprocessing import StandardScaler

print("=" * 70)
print("    RECHERCHE DU MEILLEUR NOMBRE DE VOISINS (k)")
print("=" * 70)
print("""
Le choix de k est CRUCIAL pour KNN:

• k trop petit (ex: k=1): sensible au bruit, sur-apprentissage
• k trop grand (ex: k=50): trop lisse, sous-apprentissage
• k optimal: bon equilibre biais-variance

Testons differentes valeurs!
""")

# Preparation
X = df[['sepal_length', 'sepal_width', 'petal_length', 'petal_width']].values
y = df['species'].values
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
X_train, X_test, y_train, y_test = train_test_split(X_scaled, y, test_size=0.2, random_state=42, stratify=y)

print("\n" + "=" * 70)
print("RESULTATS POUR k = 1 A 20")
print("=" * 70)

# Tester differentes valeurs de k
k_values = range(1, 21)
results = []

print(f"\n{'k':^5} | {'Accuracy':^10} | {'Train Acc':^10} | {'Commentaire':<30}")
print("-" * 65)

for k in k_values:
    knn = KNeighborsClassifier(n_neighbors=k)
    knn.fit(X_train, y_train)
    test_acc = accuracy_score(y_test, knn.predict(X_test))
    train_acc = accuracy_score(y_train, knn.predict(X_train))

    results.append({
        'k': k,
        'test_acc': test_acc,
        'train_acc': train_acc
    })

    # Commentaire sur le comportement
    if k == 1:
        comment = "k=1: risque de sur-apprentissage"
    elif k <= 3:
        comment = "petit k: sensible au bruit"
    elif k <= 9:
        comment = "zone optimale typique"
    elif k <= 15:
        comment = "k modere"
    else:
        comment = "grand k: plus lisse"

    marker = "★" if test_acc == max(r['test_acc'] for r in results) else " "
    print(f"{k:^5} | {test_acc:^10.2%} | {train_acc:^10.2%} | {comment:<30} {marker}")

print("-" * 65)

# Trouver le meilleur k
best_result = max(results, key=lambda x: x['test_acc'])
best_k = best_result['k']

print(f"\n  Meilleur k trouve: {best_k} (Accuracy: {best_result['test_acc']:.2%})")

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

test_accs = [r['test_acc'] for r in results]
train_accs = [r['train_acc'] for r in results]

# Plot 1: Accuracy vs k
axes[0].plot(list(k_values), test_accs, 'o-', color='#9B7AC4', linewidth=2, markersize=8, label='Test')
axes[0].plot(list(k_values), train_accs, 's--', color='#F7E64D', linewidth=2, markersize=6, label='Train', alpha=0.7)
axes[0].axvline(x=best_k, color='green', linestyle=':', linewidth=2, label=f'Meilleur k={best_k}')
axes[0].set_xlabel('Nombre de voisins (k)')
axes[0].set_ylabel('Accuracy')
axes[0].set_title('Accuracy selon k')
axes[0].legend()
axes[0].grid(True, alpha=0.3)
axes[0].set_xticks(list(k_values))

# Plot 2: Ecart train-test (indicateur de sur-apprentissage)
gaps = [r['train_acc'] - r['test_acc'] for r in results]
colors = ['#e74c3c' if g > 0.05 else '#27ae60' for g in gaps]
axes[1].bar(list(k_values), gaps, color=colors, edgecolor='black', alpha=0.7)
axes[1].axhline(y=0, color='black', linestyle='-', linewidth=1)
axes[1].axhline(y=0.05, color='orange', linestyle='--', linewidth=1, label='Seuil sur-apprentissage')
axes[1].set_xlabel('Nombre de voisins (k)')
axes[1].set_ylabel('Ecart (Train - Test)')
axes[1].set_title('Indicateur de sur-apprentissage')
axes[1].legend()
axes[1].grid(True, alpha=0.3, axis='y')
axes[1].set_xticks(list(k_values))

plt.tight_layout()
plt.show()

print("\n" + "=" * 70)
print("INTERPRETATION")
print("=" * 70)
print(f"""
GRAPHIQUE DE GAUCHE (Accuracy vs k):
  → L'accuracy test varie selon k
  → k={best_k} donne la meilleure performance test
  → L'accuracy train diminue avec k (normal: plus de lissage)

GRAPHIQUE DE DROITE (Sur-apprentissage):
  → Barres rouges: ecart train-test > 5% (sur-apprentissage)
  → Barres vertes: bon equilibre
  → k petit = plus d'ecart = plus de sur-apprentissage

CONSEILS PRATIQUES:
  • Choisir k impair pour eviter les egalites en classification binaire
  • k = sqrt(n) est une heuristique courante
  • Toujours valider avec cross-validation pour plus de robustesse
""")


# Importance de la normalisation
# Type: Code executable
from sklearn.neighbors import KNeighborsClassifier
from sklearn.preprocessing import StandardScaler

print("=" * 70)
print("    IMPACT CRITIQUE DE LA NORMALISATION SUR KNN")
print("=" * 70)
print("""
KNN est base sur les DISTANCES. Une feature avec une echelle
100x plus grande dominera completement le calcul!

Demontrons cet effet concretement.
""")

# Preparer les donnees
X = df[['sepal_length', 'sepal_width', 'petal_length', 'petal_width']].values
y = df['species'].values
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)

print("\n" + "=" * 70)
print("1. ECHELLES DES FEATURES (AVANT NORMALISATION)")
print("=" * 70)
features = ['sepal_length', 'sepal_width', 'petal_length', 'petal_width']

print(f"\n{'Feature':<15} | {'Min':^8} | {'Max':^8} | {'Amplitude':^10} | {'Poids relatif':<15}")
print("-" * 65)

amplitudes = []
for i, name in enumerate(features):
    min_val = X[:, i].min()
    max_val = X[:, i].max()
    amplitude = max_val - min_val
    amplitudes.append(amplitude)

max_amp = max(amplitudes)
for i, name in enumerate(features):
    min_val = X[:, i].min()
    max_val = X[:, i].max()
    amplitude = amplitudes[i]
    weight = amplitude / max_amp * 100
    bar = "█" * int(weight / 10)
    print(f"{name:<15} | {min_val:^8.1f} | {max_val:^8.1f} | {amplitude:^10.1f} | {weight:>5.0f}% {bar}")

print(f"""
Probleme:
  → sepal_length a la plus grande amplitude ({max(amplitudes):.1f})
  → Elle dominera le calcul de distance euclidienne!
""")

print("\n" + "=" * 70)
print("2. COMPARAISON: AVEC vs SANS NORMALISATION")
print("=" * 70)

# SANS normalisation
knn_raw = KNeighborsClassifier(n_neighbors=5)
knn_raw.fit(X_train, y_train)
acc_raw = accuracy_score(y_test, knn_raw.predict(X_test))

# AVEC normalisation
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)

knn_scaled = KNeighborsClassifier(n_neighbors=5)
knn_scaled.fit(X_train_scaled, y_train)
acc_scaled = accuracy_score(y_test, knn_scaled.predict(X_test_scaled))

print(f"""
Resultats (k=5):

  SANS normalisation: {acc_raw:.2%}
  AVEC normalisation: {acc_scaled:.2%}

  Difference: {(acc_scaled - acc_raw)*100:+.1f} points de pourcentage
""")

improvement = acc_scaled - acc_raw
if improvement > 0:
    print(f"  → La normalisation AMELIORE les performances!")
elif improvement < 0:
    print(f"  → Resultat surprenant - les donnees brutes performent mieux ici")
else:
    print(f"  → Performances identiques (cas rare)")

print("\n" + "=" * 70)
print("3. VISUALISATION DE L'EFFET")
print("=" * 70)

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

# Plot 1: Donnees brutes
axes[0].scatter(X[:, 0], X[:, 2], c=[{'setosa': 0, 'versicolor': 1, 'virginica': 2}[s] for s in y],
               cmap='viridis', alpha=0.6, s=60)
axes[0].set_xlabel('sepal_length (cm)')
axes[0].set_ylabel('petal_length (cm)')
axes[0].set_title(f'Donnees BRUTES (Acc: {acc_raw:.1%})')
axes[0].grid(True, alpha=0.3)

# Plot 2: Donnees normalisees
X_all_scaled = scaler.fit_transform(X)
axes[1].scatter(X_all_scaled[:, 0], X_all_scaled[:, 2], c=[{'setosa': 0, 'versicolor': 1, 'virginica': 2}[s] for s in y],
               cmap='viridis', alpha=0.6, s=60)
axes[1].set_xlabel('sepal_length (standardise)')
axes[1].set_ylabel('petal_length (standardise)')
axes[1].set_title(f'Donnees NORMALISEES (Acc: {acc_scaled:.1%})')
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

print("""
Ce qui a change:
  → Les axes ont maintenant des echelles comparables
  → Un point "proche" l'est dans toutes les dimensions

ATTENTION: Toujours utiliser scaler.transform() sur les nouvelles
donnees, pas fit_transform() (sinon vous recalculez les parametres)!
""")

print("\n" + "=" * 70)
print("CONCLUSION")
print("=" * 70)
print("""
REGLE D'OR POUR KNN:
  1. TOUJOURS normaliser les donnees (StandardScaler ou MinMaxScaler)
  2. Fit le scaler sur TRAIN uniquement
  3. Transform sur TRAIN et TEST separement

Code type:
  scaler = StandardScaler()
  X_train_scaled = scaler.fit_transform(X_train)  # fit + transform
  X_test_scaled = scaler.transform(X_test)        # transform seulement!
""")


# Exercice: KNN avec poids
# Type: Exercice
# Exercice: Utilisez des poids bases sur la distance

from sklearn.neighbors import KNeighborsClassifier
from sklearn.preprocessing import StandardScaler

# Par defaut, tous les voisins ont le meme poids
# On peut donner plus de poids aux voisins proches

# Preparez les donnees
X = df[['sepal_length', 'sepal_width', 'petal_length', 'petal_width']].values
y = df['species'].values
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
X_train, X_test, y_train, y_test = train_test_split(X_scaled, y, test_size=0.2, random_state=42)

# TODO: Comparez weights='uniform' vs weights='distance'
# TODO: Testez pour k=3, 5, 7, 9


# Visualiser les voisins d'une prediction
# Type: Code executable
from sklearn.neighbors import KNeighborsClassifier
from sklearn.preprocessing import StandardScaler

print("=" * 70)
print("    VISUALISATION: COMMENT KNN FAIT UNE PREDICTION")
print("=" * 70)
print("""
Voyons concretement comment KNN classifie un nouveau point
en trouvant ses k plus proches voisins.
""")

# Utiliser seulement 2 features pour la visualisation
feature_1, feature_2 = 'petal_length', 'petal_width'
X = df[[feature_1, feature_2]].values
y = df['species'].values

scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)

# Entrainer KNN
k = 5
knn = KNeighborsClassifier(n_neighbors=k)
knn.fit(X_scaled, y)

# Nouveau point a classifier
new_point_raw = np.array([[3.0, 1.0]])  # petal_length=3.0, petal_width=1.0
new_point_scaled = scaler.transform(new_point_raw)

print(f"\n" + "=" * 70)
print("1. NOUVEAU POINT A CLASSIFIER")
print("=" * 70)
print(f"""
Caracteristiques de la nouvelle fleur:
  • {feature_1}: {new_point_raw[0, 0]:.1f} cm
  • {feature_2}: {new_point_raw[0, 1]:.1f} cm

Question: A quelle espece appartient cette fleur?
""")

# Trouver les voisins
distances, indices = knn.kneighbors(new_point_scaled)

print("\n" + "=" * 70)
print(f"2. LES {k} PLUS PROCHES VOISINS")
print("=" * 70)
print(f"\n{'Rang':<6} | {'Distance':^10} | {'Espece':<12} | {feature_1:^12} | {feature_2:^12}")
print("-" * 60)

neighbor_species = []
for rank, (idx, dist) in enumerate(zip(indices[0], distances[0]), 1):
    species = y[idx]
    neighbor_species.append(species)
    f1 = X[idx, 0]
    f2 = X[idx, 1]
    print(f"  {rank:<4} | {dist:^10.3f} | {species:<12} | {f1:^12.1f} | {f2:^12.1f}")

print("-" * 60)

print("\n" + "=" * 70)
print("3. VOTE MAJORITAIRE")
print("=" * 70)
from collections import Counter
votes = Counter(neighbor_species)
print("\n  Decompte des votes:")
for species, count in votes.most_common():
    pct = count / k * 100
    bar = "█" * int(pct / 5)
    print(f"    {species}: {count} vote(s) ({pct:.0f}%)  {bar}")

# Prediction
prediction = knn.predict(new_point_scaled)[0]
proba = knn.predict_proba(new_point_scaled)[0]

print(f"\n  PREDICTION FINALE: {prediction}")
print(f"  Probabilites:")
for i, species in enumerate(knn.classes_):
    print(f"    P({species}) = {proba[i]:.1%}")

print("\n" + "=" * 70)
print("4. VISUALISATION GRAPHIQUE")
print("=" * 70)

# Visualisation
plt.figure(figsize=(10, 7))

colors = {'setosa': '#9B7AC4', 'versicolor': '#C09CF0', 'virginica': '#E5D7F5'}
for species in df['species'].unique():
    mask = y == species
    plt.scatter(X[mask, 0], X[mask, 1], c=colors[species], label=species,
               alpha=0.5, s=40, edgecolors='gray')

# Nouveau point
plt.scatter(new_point_raw[0, 0], new_point_raw[0, 1], c='#F7E64D', s=300, marker='*',
           edgecolors='black', linewidths=2, label='Nouveau point', zorder=5)

# Voisins et lignes de connexion
for i, idx in enumerate(indices[0]):
    plt.scatter(X[idx, 0], X[idx, 1], s=150, facecolors='none',
               edgecolors='red', linewidths=2, zorder=4)
    plt.plot([new_point_raw[0, 0], X[idx, 0]], [new_point_raw[0, 1], X[idx, 1]],
            'r--', alpha=0.5, linewidth=1)
    # Annoter le rang
    plt.annotate(f'{i+1}', (X[idx, 0], X[idx, 1]),
                textcoords="offset points", xytext=(5, 5), fontsize=9, color='red')

plt.xlabel(f'{feature_1} (cm)')
plt.ylabel(f'{feature_2} (cm)')
plt.title(f'KNN (k={k}): Prediction = {prediction}')
plt.legend(loc='upper left')
plt.grid(True, alpha=0.3)
plt.show()

print("""
LEGENDE:
  • Etoile jaune: nouveau point a classifier
  • Cercles rouges: les k plus proches voisins
  • Lignes pointillees: distances aux voisins
  • Numeros: rang du voisin (1 = le plus proche)
""")

print("\n" + "=" * 70)
print("CONCLUSION")
print("=" * 70)
print(f"""
Le nouveau point ({feature_1}={new_point_raw[0,0]}, {feature_2}={new_point_raw[0,1]})
est classe comme '{prediction}'.

Les {k} voisins ont vote:
""")
for species, count in votes.most_common():
    print(f"    • {species}: {count} vote(s)")

print(f"""
C'est l'essence de KNN: la classification se fait par
vote democratique des plus proches voisins!
""")

