Scikit-Learn : la régression logistique

scikit learn regression logistique

Ce module se concentre sur la régression logistique avec Scikit-Learn, un algorithme fondamental de classification en apprentissage supervisé.

Malgré son nom qui contient le terme « régression », il s’agit en réalité d’une méthode de classification permettant de prédire des catégories discrètes plutôt que des valeurs continues.

Vous découvrirez pourquoi cet algorithme est considéré comme la pierre angulaire de nombreuses applications en science des données, de la finance à la médecine.

Ce que vous allez apprendre

  • Les fondements théoriques et intuitifs de la régression logistique
  • Comment transformer une relation linéaire en probabilité d’appartenance à une classe
  • Les techniques de préparation des données spécifiques à cet algorithme
  • L’implémentation et la configuration détaillée avec scikit-learn
  • Les méthodes d’évaluation et d’interprétation particulières à la classification binaire
  • Les techniques avancées pour optimiser les performances du modèle

Ce que vous devez maîtriser

  • Les bases de Python et de ses bibliothèques scientifiques (numpy, pandas)
  • Les concepts élémentaires de probabilités (notions de probabilité conditionnelle)
  • La compréhension intuitive des logarithmes et de l’exponentielle
  • L’utilisation basique de scikit-learn (importation de modules, création d’objets)

La Régression Logistique : principes de base

Qu’est-ce que la régression logistique ?

RAPPEL : Différence entre classification et régression
La régression prédit une valeur numérique continue (ex: prix, température)
La classification prédit une catégorie discrète (ex: spam/non-spam, malade/sain)

La régression logistique est un modèle statistique permettant de prédire la probabilité qu’une observation appartienne à une catégorie particulière. C’est l’un des algorithmes de classification les plus anciens et les plus utilisés en science des données pour plusieurs raisons :

  • Simplicité : facile à comprendre et à mettre en œuvre
  • Interprétabilité : les coefficients ont une signification claire
  • Efficacité : performances souvent comparables à des modèles plus complexes
  • Probabilités : fournit naturellement des estimations de probabilité, pas seulement des classes

Intuition : du linéaire au probabiliste

Imaginez que vous souhaitiez prédire si un étudiant sera admis à l’université en fonction de son score à un test. Avec une régression linéaire, vous obtiendriez des valeurs pouvant être négatives ou supérieures à 1, ce qui n’a pas de sens pour une probabilité. La régression logistique résout ce problème en « écrasant » le résultat linéaire entre 0 et 1 à l’aide de la fonction sigmoïde.

import numpy as np
import matplotlib.pyplot as plt

# Exemple simple : prédire l'admission en fonction du score d'examen
scores = np.array([30, 40, 50, 60, 70, 80, 90])
admitted = np.array([0, 0, 0, 1, 1, 1, 1])  # 0 = non admis, 1 = admis

# Visualisation des données
plt.figure(figsize=(10, 6))
plt.scatter(scores, admitted, s=100, c=admitted, cmap='coolwarm', edgecolors='k')
plt.xlabel('Score au test', fontsize=14)
plt.ylabel('Admission (0 = non, 1 = oui)', fontsize=14)
plt.title('Données d\'admission', fontsize=16)
plt.yticks([0, 1])

# Régression linéaire (pour illustrer ses limites)
coefficient = np.polyfit(scores, admitted, 1)
line = coefficient[0] * scores + coefficient[1]
plt.plot(scores, line, 'g--', label='Régression linéaire')

# Contraintes problématiques de la régression linéaire
x_extended = np.array([20, 100])
y_extended = coefficient[0] * x_extended + coefficient[1]
plt.plot(x_extended, y_extended, 'r--', alpha=0.5)
plt.text(20, y_extended[0] - 0.1, f'Proba = {y_extended[0]:.2f} ?!', color='red')
plt.text(100, y_extended[1] + 0.1, f'Proba = {y_extended[1]:.2f} ?!', color='red')

# Modèle logistique
from sklearn.linear_model import LogisticRegression
X = scores.reshape(-1, 1)
model = LogisticRegression().fit(X, admitted)
score_range = np.linspace(20, 100, 100).reshape(-1, 1)
proba = model.predict_proba(score_range)[:, 1]

plt.plot(score_range, proba, 'b-', label='Régression logistique')
plt.axhline(y=0, color='k', linestyle='-', alpha=0.3)
plt.axhline(y=1, color='k', linestyle='-', alpha=0.3)
plt.grid(True, alpha=0.3)
plt.legend(fontsize=12)
plt.tight_layout()
plt.show()

ANALOGIE
Imaginez une pente (modèle linéaire) qui se transforme en un escalier courbé et doux (sigmoïde). Sur un escalier normal, vous êtes soit sur une marche, soit sur une autre (0 ou 1), mais sur cet escalier courbe, vous pouvez vous trouver à n’importe quelle hauteur entre le sol (0) et le palier (1).

Formulation mathématique

La fonction sigmoïde : le cœur de la régression logistique

La fonction sigmoïde (ou logistique) transforme n’importe quelle valeur réelle en une valeur entre 0 et 1 :

[mathjax]$$\sigma(z) = \frac{1}{1 + e^{-z}}$$

Où $$z$$ est le résultat d’une combinaison linéaire des caractéristiques :

$$z = w_0 + w_1x_1 + w_2x_2 + … + w_nx_n = w_0 + \sum_{i=1}^{n} w_i x_i$$[mathjax]

VISUALISATION DE LA FONCTION SIGMOÏDE

# Visualisation détaillée de la fonction sigmoïde
z = np.linspace(-8, 8, 1000)
sigma = 1 / (1 + np.exp(-z))

plt.figure(figsize=(12, 7))
plt.plot(z, sigma, 'b-', linewidth=2.5)
plt.grid(True, alpha=0.3)
plt.axhline(y=0.5, color='r', linestyle='--', alpha=0.7, label='Seuil de décision (0.5)')
plt.axvline(x=0, color='g', linestyle='--', alpha=0.7, label='z = 0')
plt.axhline(y=0, color='k', linestyle='-', alpha=0.3)
plt.axhline(y=1, color='k', linestyle='-', alpha=0.3)

# Annotations pédagogiques
plt.fill_between(z, sigma, 0, where=(z < 0), color='lightcoral', alpha=0.3, 
                 label='Prédiction classe 0')
plt.fill_between(z, sigma, 0, where=(z >= 0), color='lightgreen', alpha=0.3,
                 label='Prédiction classe 1')
plt.text(-6, 0.1, 'Forte certitude\nclasse 0', fontsize=12, ha='center')
plt.text(6, 0.9, 'Forte certitude\nclasse 1', fontsize=12, ha='center')
plt.text(0, 0.25, 'Incertitude\nmaximale', fontsize=12, ha='center')

# Quelques points spécifiques
for val in [-4, -2, 0, 2, 4]:
    sig_val = 1 / (1 + np.exp(-val))
    plt.plot(val, sig_val, 'ko', markersize=6)
    plt.text(val+0.5, sig_val, f'z = {val}: σ(z) = {sig_val:.3f}', fontsize=10)

plt.title('Fonction sigmoïde: σ(z) = 1 / (1 + e^(-z))', fontsize=16)
plt.xlabel('z = w₀ + w₁x₁ + w₂x₂ + ... + wₙxₙ', fontsize=14)
plt.ylabel('σ(z) = P(y=1|x)', fontsize=14)
plt.xlim([-8, 8])
plt.ylim([-0.1, 1.1])
plt.legend(fontsize=12, loc='center right')
plt.tight_layout()
plt.show()

Décision de classification

Une fois que nous avons calculé la probabilité, nous prenons une décision de classification en comparant cette probabilité à un seuil (par défaut 0.5) :

Si $$P(y=1|x) \geq 0.5$$, alors prédire la classe 1

Si $$P(y=1|x) < 0.5$$, alors prédire la classe 0

POUR ALLER PLUS LOIN : INTERPRÉTATION EN TERMES DE LOG-ODDS
La régression logistique peut aussi être exprimée en termes de log-odds (logarithme du rapport de cotes) : $$\log\left(\frac{P(y=1|x)}{1-P(y=1|x)}\right) = w_0 + w_1x_1 + w_2x_2 + … + w_nx_n$$

Cette formulation montre que chaque coefficient $$w_i$$ représente le changement dans le log-odds lorsque la variable $$x_i$$ augmente d’une unité, toutes les autres variables restant constantes. C’est l’une des raisons pour lesquelles la régression logistique est très appréciée pour son interprétabilité.

Fonction de coût : l’entropie croisée binaire

Pour entraîner un modèle de régression logistique, nous devons définir une fonction de coût à minimiser. La fonction adaptée est l’entropie croisée binaire :

$$J(w) = -\frac{1}{m}\sum_{i=1}^{m}\left[y^{(i)}\log(p^{(i)}) + (1-y^{(i)})\log(1-p^{(i)})\right]$$

Où :

$$m$$ est le nombre d’exemples d’entraînement

$$y^{(i)}$$ est la vraie classe de l’exemple $i$ (0 ou 1)

$$p^{(i)} = P(y=1|x^{(i)})$$ est la probabilité prédite que l’exemple $$i$$ appartienne à la classe 1

INTUITION DERRIÈRE LA FONCTION DE COÛT
L’entropie croisée pénalise fortement les mauvaises prédictions confiantes. Si la vraie classe est 1 mais que le modèle prédit une probabilité proche de 0, ou vice versa, la pénalité sera très élevée. Cette fonction encourage le modèle à être à la fois précis et bien calibré en termes de probabilités.

Avantages et limites

Avantages

  1. Interprétabilité : Les coefficients $$w_i$$ ont une interprétation directe en termes d’impact sur les odds (rapports de cotes).
  2. Simplicité : Facile à comprendre, implémenter et déboguer.
  3. Efficacité : Entraînement rapide, prédictions peu coûteuses en calcul.
  4. Calibration des probabilités : Fournit naturellement des probabilités bien calibrées, contrairement à d’autres classifieurs.
  5. Extensibilité : Peut être étendu à la classification multi-classe (régression logistique multinomiale).

Limites

  1. Hypothèse de linéarité : Suppose une relation linéaire entre les variables et le log des odds, ce qui n’est pas toujours vrai.
# Importation des bibliothèques nécessaires
from sklearn.datasets import make_circles  # Pour générer un jeu de données de cercles concentriques
from sklearn.linear_model import LogisticRegression # Pour utiliser le modèle de régression logistique
from sklearn.preprocessing import PolynomialFeatures # Pour créer des caractéristiques polynomiales
import matplotlib.pyplot as plt # Pour la visualisation des données et des résultats (correction ici)
import numpy as np # Pour les opérations numériques, notamment la création de la grille pour la frontière de décision

# Créer un jeu de données circulaire (non linéairement séparable)
# n_samples: nombre total de points générés.
# noise: écart-type du bruit gaussien ajouté aux données.
# factor: facteur d'échelle entre les deux cercles. Une valeur plus petite rend les cercles plus distincts.
# random_state: graine pour la reproductibilité des résultats.
X, y = make_circles(n_samples=1000, noise=0.1, factor=0.5, random_state=42)

# Entraîner une régression logistique standard
# Initialisation du modèle de régression logistique.
model_simple = LogisticRegression()
# Entraînement du modèle sur les données X (caractéristiques) et y (cibles).
# Ce modèle va tenter de trouver une frontière de décision linéaire.
model_simple.fit(X, y)

# Créer des caractéristiques polynomiales pour capturer la non-linéarité
# PolynomialFeatures génère de nouvelles caractéristiques qui sont des combinaisons polynomiales
# des caractéristiques originales.
# degree=2 signifie que nous allons créer des caractéristiques jusqu'au degré 2 (ex: x1, x2, x1^2, x1*x2, x2^2).
poly = PolynomialFeatures(degree=2)
# Appliquer la transformation polynomiale aux données X.
X_poly = poly.fit_transform(X)

# Entraîner une régression logistique avec les caractéristiques polynomiales
# Initialisation d'un nouveau modèle de régression logistique.
model_poly = LogisticRegression()
# Entraînement du modèle sur les nouvelles caractéristiques polynomiales X_poly et les cibles y.
# Ce modèle pourra trouver une frontière de décision non-linéaire grâce aux caractéristiques transformées.
model_poly.fit(X_poly, y)

# Visualiser les résultats
# Création d'une figure et de deux sous-graphiques (axes) pour afficher les résultats côte à côte.
# figsize définit la taille de la figure.
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(15, 6))

# Fonction pour tracer les frontières de décision
def plot_decision_boundary(model, X, y, ax, title, polynomial=False):
    """
    Trace la frontière de décision d'un modèle de classification.

    Paramètres:
    model : Le modèle entraîné.
    X : Les caractéristiques des données.
    y : Les étiquettes des données.
    ax : L'axe matplotlib sur lequel tracer.
    title : Le titre du graphique.
    polynomial (bool) : Indique si les caractéristiques polynomiales doivent être utilisées
                         pour la prédiction de la grille.
    """
    h = 0.02  # pas pour la grille de points sur laquelle on va prédire

    # Définir les limites de la grille pour couvrir l'étendue des données
    x_min, x_max = X[:, 0].min() - 1, X[:, 0].max() + 1
    y_min, y_max = X[:, 1].min() - 1, X[:, 1].max() + 1

    # Créer une grille de points (xx, yy) sur laquelle le modèle fera des prédictions
    # np.meshgrid crée des matrices de coordonnées à partir de vecteurs de coordonnées.
    xx, yy = np.meshgrid(np.arange(x_min, x_max, h),
                         np.arange(y_min, y_max, h))

    # Prédire la classe pour chaque point de la grille
    # np.c_ concatène les arrays xx.ravel() et yy.ravel() en colonnes.
    # .ravel() aplatit les matrices xx et yy en vecteurs.
    if polynomial:
        # Si le modèle utilise des caractéristiques polynomiales,
        # il faut transformer les points de la grille avec 'poly.transform'
        # avant de faire la prédiction.
        Z = model.predict(poly.transform(np.c_[xx.ravel(), yy.ravel()]))
    else:
        # Sinon, on prédit directement sur les points de la grille.
        Z = model.predict(np.c_[xx.ravel(), yy.ravel()])

    # Remettre Z dans la même forme que xx et yy pour pouvoir tracer les contours.
    Z = Z.reshape(xx.shape)

    # Tracer les régions de décision colorées (frontière de décision)
    # ax.contourf remplit les contours. alpha gère la transparence. cmap est la palette de couleurs.
    ax.contourf(xx, yy, Z, alpha=0.3, cmap='coolwarm')

    # Afficher les points de données originaux sur le graphique
    # c=y colore les points en fonction de leur classe.
    # edgecolors='k' ajoute un contour noir aux points. s est la taille des points.
    ax.scatter(X[:, 0], X[:, 1], c=y, cmap='coolwarm', edgecolors='k', s=40)

    # Définir le titre et les étiquettes des axes
    ax.set_title(title, fontsize=14)
    ax.set_xlabel('Caractéristique 1', fontsize=12)
    ax.set_ylabel('Caractéristique 2', fontsize=12)

# Tracer les deux modèles
# Appel de la fonction pour tracer la frontière de décision du modèle simple.
plot_decision_boundary(model_simple, X, y, ax1, 'Régression logistique standard')
# Appel de la fonction pour tracer la frontière de décision du modèle avec caractéristiques polynomiales.
# On passe polynomial=True pour indiquer à la fonction d'utiliser poly.transform.
plot_decision_boundary(model_poly, X, y, ax2, 'Régression logistique + caractéristiques polynomiales', polynomial=True) # Correction: polynomial=True

# Ajuster automatiquement la disposition des sous-graphiques pour éviter les chevauchements.
plt.tight_layout()
# Afficher la figure avec les graphiques.
plt.show()
Régression logistique standard vs Régression logistique + caractéristiques polynomiales
  1. Sensibilité aux valeurs aberrantes : Peut être fortement influencée par des valeurs extrêmes, surtout avec peu de données.
  2. Problèmes de séparation parfaite : Quand les classes sont parfaitement séparables, les coefficients peuvent tendre vers l’infini.

ASTUCE PRATIQUE
En cas de séparation parfaite, la régularisation devient essentielle pour obtenir des coefficients stables. Utilisez un paramètre C plus petit (régularisation plus forte) dans scikit-learn.

  1. Multicolinéarité : Problèmes de stabilité numérique et d’interprétation quand les variables explicatives sont fortement corrélées.
  2. Traitement des grands jeux de données : Peut être lent à entraîner sur des données volumineuses sans techniques d’optimisation spécifiques.

POUR ALLER PLUS LOIN : LA RÉGULARISATION
La régression logistique inclut généralement un terme de régularisation pour éviter le surapprentissage :

L2 (Ridge) : Pénalise la somme des carrés des coefficients
$$J(w) = -\frac{1}{m}\sum_{i=1}^{m}[y^{(i)}\log(p^{(i)}) + (1-y^{(i)})\log(1-p^{(i)})] + \frac{\lambda}{2m}\sum_{j=1}^{n}w_j^2$$

L1 (Lasso) : Pénalise la somme des valeurs absolues des coefficients
$$J(w) = -\frac{1}{m}\sum_{i=1}^{m}[y^{(i)}\log(p^{(i)}) + (1-y^{(i)})\log(1-p^{(i)})] + \frac{\lambda}{m}\sum_{j=1}^{n}|w_j|$$

Le paramètre $$\lambda$$ contrôle la force de la régularisation (inversement proportionnel au paramètre C dans scikit-learn).

Préparation des données pour la Régression Logistique

Exploration et nettoyage des données

Une préparation minutieuse des données est cruciale pour les performances de la régression logistique. Commençons par explorer et nettoyer notre jeu de données.

# Importation des bibliothèques nécessaires
import pandas as pd  # pandas est utilisé pour la manipulation et l'analyse de données tabulaires (DataFrames)
import numpy as np   # numpy est utilisé pour les opérations numériques, notamment pour la création de la matrice de masquage
import matplotlib.pyplot as plt # matplotlib.pyplot est une collection de fonctions qui font fonctionner matplotlib comme MATLAB, utilisée pour créer des graphiques statiques, animés et interactifs
import seaborn as sns # seaborn est une bibliothèque de visualisation de données Python basée sur matplotlib. Elle fournit une interface de haut niveau pour dessiner des graphiques statistiques attrayants et informatifs.

# Configuration pour de meilleurs graphiques
# 'seaborn-v0_8-whitegrid' applique un style prédéfini de Seaborn avec une grille blanche, améliorant l'esthétique des graphiques.
plt.style.use('seaborn-v0_8-whitegrid')
# 'Set2' est une palette de couleurs qualitative de Seaborn, utile pour distinguer les catégories.
sns.set_palette('Set2')

# Chargement des données à partir d'une URL en ligne
# pd.read_csv peut charger des données directement à partir d'une URL.
# Voici une URL courante pour un jeu de données d'admissions similaire.
url_donnees_admissions = "https://stats.idre.ucla.edu/stat/data/binary.csv"
df = pd.read_csv(url_donnees_admissions)

# Renommer les colonnes pour correspondre au script original si nécessaire
# Le jeu de données de l'UCLA a des noms de colonnes comme 'gre', 'gpa', 'rank' (pour prestige) et 'admit'.
# Si les noms étaient différents, on pourrait les renommer ici.
# Par exemple, si 'rank' devait être 'prestige':
# df.rename(columns={'rank': 'prestige'}, inplace=True)
# Dans ce cas, 'rank' est utilisé à la place de 'prestige'. Nous allons l'adapter dans le code ci-dessous ou le renommer.
# Pour la cohérence avec le script original, renommons 'rank' en 'prestige'.
if 'rank' in df.columns:
    df.rename(columns={'rank': 'prestige'}, inplace=True)


# Aperçu initial
# df.shape retourne un tuple représentant les dimensions (lignes, colonnes) du DataFrame.
print("Dimensions du DataFrame:", df.shape)
print("\nAperçu des données:")
# df.head() affiche les 5 premières lignes du DataFrame par défaut, donnant un premier aperçu des données.
print(df.head())

# Types de données et valeurs manquantes
print("\nTypes de données et valeurs manquantes:")
# df.info() fournit un résumé concis du DataFrame, incluant le type de chaque colonne,
# le nombre de valeurs non nulles, et l'utilisation de la mémoire. C'est utile pour identifier les valeurs manquantes.
print(df.info())

# Statistiques descriptives
print("\nStatistiques descriptives:")
# df.describe() génère des statistiques descriptives pour les colonnes numériques du DataFrame,
# telles que la moyenne, l'écart-type, le min, le max, et les quartiles.
print(df.describe())

# Visualisation de la distribution des variables
# plt.subplots crée une figure et un ensemble de sous-graphiques (axes).
# Ici, on crée une grille de 2x2 sous-graphiques. figsize contrôle la taille de la figure.
fig, axes = plt.subplots(2, 2, figsize=(15, 10))

# Distribution des scores GRE
# sns.histplot crée un histogramme pour visualiser la distribution d'une variable.
# kde=True ajoute une estimation de la densité du noyau (Kernel Density Estimate) pour lisser la distribution.
# bins=20 spécifie le nombre de barres dans l'histogramme.
# ax=axes[0, 0] spécifie que ce graphique doit être tracé sur le premier sous-graphique (en haut à gauche).
sns.histplot(df['gre'], kde=True, bins=20, ax=axes[0, 0])
# axvline trace une ligne verticale. Ici, on trace la moyenne des scores GRE en rouge.
axes[0, 0].axvline(df['gre'].mean(), color='r', linestyle='--', label='Moyenne')
# On trace la médiane des scores GRE en vert.
axes[0, 0].axvline(df['gre'].median(), color='g', linestyle='--', label='Médiane')
axes[0, 0].set_title('Distribution des scores GRE', fontsize=14) # Définit le titre du sous-graphique.
axes[0, 0].legend() # Affiche la légende (pour la moyenne et la médiane).

# Distribution des moyennes GPA
sns.histplot(df['gpa'], kde=True, bins=20, ax=axes[0, 1]) # Similaire pour la variable 'gpa' sur le deuxième sous-graphique.
axes[0, 1].axvline(df['gpa'].mean(), color='r', linestyle='--', label='Moyenne')
axes[0, 1].axvline(df['gpa'].median(), color='g', linestyle='--', label='Médiane')
axes[0, 1].set_title('Distribution des moyennes GPA', fontsize=14)
axes[0, 1].legend()

# Distribution des niveaux de prestige
# df['prestige'].value_counts() compte le nombre d'occurrences de chaque valeur unique dans la colonne 'prestige'.
# .sort_index() trie les résultats par l'index (les niveaux de prestige).
prestige_counts = df['prestige'].value_counts().sort_index()
# axes[1, 0].bar crée un diagramme à barres.
axes[1, 0].bar(prestige_counts.index, prestige_counts.values)
axes[1, 0].set_xticks(prestige_counts.index) # S'assure que toutes les étiquettes de prestige sont affichées sur l'axe x.
axes[1, 0].set_title('Distribution des niveaux de prestige', fontsize=14)
axes[1, 0].set_xlabel('Niveau de prestige (1 = meilleur)') # Étiquette de l'axe X.

# Distribution des admissions
# df['admit'].value_counts() compte le nombre d'admis (1) et de non-admis (0).
admission_counts = df['admit'].value_counts()
# axes[1, 1].pie crée un diagramme circulaire (camembert).
# labels définit les étiquettes pour chaque part.
# autopct='%1.1f%%' formate le pourcentage affiché sur chaque part.
# colors définit les couleurs des parts.
# explode=(0, 0.1) "explose" la deuxième part (Admis) pour la mettre en évidence.
axes[1, 1].pie(admission_counts.values, labels=['Non admis', 'Admis'], autopct='%1.1f%%',
                  colors=['lightcoral', 'lightgreen'], explode=(0, 0.1))
axes[1, 1].set_title('Proportion d\'admissions', fontsize=14)

# plt.tight_layout() ajuste automatiquement les paramètres des sous-graphiques pour donner une disposition serrée et éviter les chevauchements.
plt.tight_layout()
# plt.show() affiche tous les graphiques créés.
plt.show()

# Analyse des corrélations
# plt.figure crée une nouvelle figure. figsize contrôle sa taille.
plt.figure(figsize=(10, 8))
# df.corr() calcule la matrice de corrélation par paire de toutes les colonnes numériques.
corr_matrix = df.corr()
# np.triu crée un masque triangulaire supérieur.
# np.ones_like(corr_matrix, dtype=bool) crée une matrice de booléens de la même forme que corr_matrix, remplie de True.
# Le masque est utilisé pour n'afficher que la partie inférieure de la matrice de corrélation (évite la redondance).
mask = np.triu(np.ones_like(corr_matrix, dtype=bool))
# sns.heatmap visualise la matrice de corrélation sous forme de carte thermique.
# mask=mask applique le masque.
# annot=True affiche les valeurs de corrélation sur les cellules.
# fmt=".2f" formate les annotations à deux décimales.
# cmap='coolwarm' définit la palette de couleurs.
# vmin, vmax, center définissent les limites et le centre de l'échelle de couleurs.
# square=True force les cellules à être carrées.
# linewidths ajoute des lignes entre les cellules.
sns.heatmap(corr_matrix, mask=mask, annot=True, fmt=".2f", cmap='coolwarm',
            vmin=-1, vmax=1, center=0, square=True, linewidths=.5)
plt.title('Matrice de corrélation', fontsize=16) # Titre du graphique.
plt.tight_layout()
plt.show()

# Analyse des relations avec la variable cible ('admit')
# Création d'une nouvelle figure avec 1 ligne et 3 colonnes de sous-graphiques.
fig, axes = plt.subplots(1, 3, figsize=(18, 6))

# GRE vs Admission
# sns.boxplot crée un diagramme en boîte (box plot) pour visualiser la distribution du score GRE
# en fonction du statut d'admission ('admit').
# x='admit' est la variable catégorielle sur l'axe des x.
# y='gre' est la variable numérique sur l'axe des y.
# data=df spécifie le DataFrame à utiliser.
sns.boxplot(x='admit', y='gre', data=df, ax=axes[0])
axes[0].set_title('Score GRE par statut d\'admission', fontsize=14)
# set_xticklabels personnalise les étiquettes de l'axe x pour plus de clarté.
axes[0].set_xticklabels(['Non admis', 'Admis'])

# GPA vs Admission
# Similaire pour la variable 'gpa'.
sns.boxplot(x='admit', y='gpa', data=df, ax=axes[1])
axes[1].set_title('GPA par statut d\'admission', fontsize=14)
axes[1].set_xticklabels(['Non admis', 'Admis'])

# Prestige vs Admission
# pd.crosstab crée un tableau croisé dynamique pour compter les occurrences d'admissions
# pour chaque niveau de prestige.
admit_by_prestige = pd.crosstab(df['prestige'], df['admit'])
# Calcule le taux d'admission pour chaque niveau de prestige.
# admit_by_prestige[1] est le nombre d'admis.
# (admit_by_prestige[0] + admit_by_prestige[1]) est le nombre total de candidats.
admit_rate = admit_by_prestige[1] / (admit_by_prestige[0] + admit_by_prestige[1])
# Crée un diagramme à barres pour visualiser le taux d'admission par prestige.
axes[2].bar(admit_rate.index, admit_rate.values * 100) # Multiplie par 100 pour afficher en pourcentage.
axes[2].set_ylim([0, 100]) # Définit les limites de l'axe y de 0 à 100%.
axes[2].set_title('Taux d\'admission (%) par niveau de prestige', fontsize=14)
axes[2].set_xlabel('Niveau de prestige (1 = meilleur)')
axes[2].set_ylabel('Taux d\'admission (%)')
axes[2].set_xticks(admit_rate.index) # S'assure que tous les niveaux de prestige sont affichés comme étiquettes.

plt.tight_layout()
plt.show()

CONSEIL : GESTION DES VALEURS MANQUANTES
Pour la régression logistique, vous pouvez :

  1. Supprimer les lignes avec valeurs manquantes si elles sont peu nombreuses
  2. Imputer les valeurs manquantes (moyenne/médiane pour variables numériques, mode pour variables catégorielles)
  3. Créer un indicateur de valeur manquante comme feature supplémentaire

Le choix dépend du contexte et de la quantité de données disponibles.

# Traitement des valeurs manquantes
print("Valeurs manquantes par colonne:")
print(df.isnull().sum())

# Approche 1: Supprimer les lignes avec valeurs manquantes
df_clean = df.dropna()
print(f"\nTaille après suppression des valeurs manquantes: {df_clean.shape[0]} lignes (perte de {df.shape[0] - df_clean.shape[0]} lignes)")

# Approche 2: Imputation
from sklearn.impute import SimpleImputer

# Séparation des variables numériques et catégorielles
numeric_cols = ['gre', 'gpa']
categorical_cols = ['prestige']

# Imputation pour variables numériques (médiane)
num_imputer = SimpleImputer(strategy='median')
df[numeric_cols] = num_imputer.fit_transform(df[numeric_cols])

# Imputation pour variables catégorielles (mode)
cat_imputer = SimpleImputer(strategy='most_frequent')
df[categorical_cols] = cat_imputer.fit_transform(df[categorical_cols].values.reshape(-1, 1))

print(f"Taille après imputation: {df.shape[0]} lignes (aucune perte)")
print("Valeurs manquantes restantes:", df.isnull().sum().sum())

Transformation des variables

La régression logistique requiert une attention particulière aux transformations de variables.

Mise à l’échelle des variables numériques

La régression logistique n’exige pas strictement la normalisation des variables, mais celle-ci présente plusieurs avantages :

  • Accélération de la convergence de l’algorithme d’optimisation
  • Comparabilité directe des coefficients
  • Amélioration de la stabilité numérique
# Importation des bibliothèques nécessaires
import pandas as pd
import numpy as np # Utilisé par pandas et sklearn
from sklearn.impute import SimpleImputer # Pour l'imputation
from sklearn.preprocessing import StandardScaler, MinMaxScaler # Pour la mise à l'échelle
import matplotlib.pyplot as plt # Pour la visualisation

# Configuration pour l'affichage des DataFrames pandas (optionnel, mais utile)
pd.set_option('display.max_columns', None) # Afficher toutes les colonnes
pd.set_option('display.width', 1000) # Augmenter la largeur d'affichage

# --- 1. Chargement des données ---
url_donnees_admissions = "https://stats.idre.ucla.edu/stat/data/binary.csv"
df = pd.read_csv(url_donnees_admissions)

# Renommer la colonne 'rank' en 'prestige' pour la cohérence avec les scripts précédents
if 'rank' in df.columns:
    df.rename(columns={'rank': 'prestige'}, inplace=True)

print("Aperçu des données originales:")
print(df.head())
print(f"\nDimensions initiales : {df.shape}")
print("\nValeurs manquantes initiales par colonne:")
print(df.isnull().sum())


# --- 2. Imputation des valeurs manquantes (pour s'assurer que les données sont propres avant la mise à l'échelle) ---
# Même si ce jeu de données spécifique est propre, cette étape est cruciale dans un pipeline réel.

# Séparation des variables numériques et catégorielles pour l'imputation
# 'admit' est la cible, on ne l'impute généralement pas ou différemment.
numeric_cols_impute = ['gre', 'gpa']
# 'prestige' est traité comme catégoriel pour l'imputation du mode.
categorical_cols_impute = ['prestige']

# Imputation pour variables numériques (par la médiane)
num_imputer = SimpleImputer(strategy='median')
df[numeric_cols_impute] = num_imputer.fit_transform(df[numeric_cols_impute])

# Imputation pour variables catégorielles (par le mode - la valeur la plus fréquente)
cat_imputer = SimpleImputer(strategy='most_frequent')
df[categorical_cols_impute] = cat_imputer.fit_transform(df[categorical_cols_impute].values.reshape(-1, 1))

print("\nValeurs manquantes après imputation (devrait être 0 partout):")
print(df.isnull().sum())
print("\nAperçu du DataFrame après imputation (devrait être identique si pas de NaN initiaux):")
print(df.head())


# --- 3. Mise à l'échelle des caractéristiques (Feature Scaling) ---
print("\n--- Début de la mise à l'échelle des caractéristiques ---")

# Sélection des colonnes numériques pour la mise à l'échelle
# Ce sont les mêmes que celles imputées, mais on les redéfinit pour la clarté de cette section.
numeric_features_scale = ['gre', 'gpa']

# Visualisation avant normalisation/standardisation
plt.figure(figsize=(12, 6)) # Augmentation légère de la hauteur pour une meilleure lisibilité
plt.subplot(1, 2, 1)
# Création d'une liste de séries pour boxplot
data_to_plot_before = [df[col] for col in numeric_features_scale]
plt.boxplot(data_to_plot_before, labels=numeric_features_scale)
plt.title('Distribution avant mise à l\'échelle', fontsize=14)
plt.ylabel('Valeurs originales') # Ajout d'une étiquette pour l'axe Y
plt.grid(True, alpha=0.3)

# Méthode 1: Standardisation (moyenne=0, écart-type=1)
# Les données sont transformées pour avoir une moyenne de 0 et un écart-type de 1.
scaler = StandardScaler()
df_scaled = df.copy() # Créer une copie pour ne pas modifier le DataFrame original 'df'
df_scaled[numeric_features_scale] = scaler.fit_transform(df[numeric_features_scale])

# Méthode 2: Normalisation Min-Max (valeurs entre 0 et 1)
# Les données sont transformées pour être comprises dans un intervalle [0, 1].
min_max_scaler = MinMaxScaler()
df_min_max = df.copy() # Créer une autre copie pour cette méthode
df_min_max[numeric_features_scale] = min_max_scaler.fit_transform(df[numeric_features_scale])

# Visualisation après standardisation
plt.subplot(1, 2, 2)
data_to_plot_after_std = [df_scaled[col] for col in numeric_features_scale]
plt.boxplot(data_to_plot_after_std, labels=numeric_features_scale)
plt.title('Distribution après standardisation (Z-score)', fontsize=14)
plt.ylabel('Valeurs standardisées') # Ajout d'une étiquette pour l'axe Y
plt.grid(True, alpha=0.3)
plt.tight_layout() # Ajuste automatiquement les subplots pour éviter les chevauchements
plt.show()

# Comparaison visuelle des différentes mises à l'échelle sur un nuage de points
fig, axes = plt.subplots(1, 3, figsize=(18, 6)) # Augmentation légère de la hauteur

# Données originales
# 'c=df['admit']' colore les points en fonction de la colonne 'admit'.
# 'cmap='coolwarm'' définit la palette de couleurs.
scatter_orig = axes[0].scatter(df['gre'], df['gpa'], c=df['admit'], cmap='coolwarm',
                               alpha=0.7, edgecolors='k')
axes[0].set_title('Données originales', fontsize=14)
axes[0].set_xlabel('Score GRE')
axes[0].set_ylabel('GPA')
axes[0].grid(True, alpha=0.3)

# Données standardisées (Z-score)
scatter_std = axes[1].scatter(df_scaled['gre'], df_scaled['gpa'], c=df_scaled['admit'], # Utiliser df_scaled['admit'] ou df['admit']
                              cmap='coolwarm', alpha=0.7, edgecolors='k')
axes[1].set_title('Après standardisation (Z-score)', fontsize=14)
axes[1].set_xlabel('Score GRE (standardisé)')
axes[1].set_ylabel('GPA (standardisé)')
axes[1].grid(True, alpha=0.3)

# Données normalisées Min-Max
scatter_minmax = axes[2].scatter(df_min_max['gre'], df_min_max['gpa'], c=df_min_max['admit'], # Utiliser df_min_max['admit'] ou df['admit']
                                 cmap='coolwarm', alpha=0.7, edgecolors='k')
axes[2].set_title('Après normalisation Min-Max', fontsize=14)
axes[2].set_xlabel('Score GRE (normalisé [0,1])')
axes[2].set_ylabel('GPA (normalisé [0,1])')
axes[2].grid(True, alpha=0.3)

# Création d'une légende commune pour la variable 'admit'
# Utiliser les éléments de l'un des scatter plots pour créer la légende.
# Les étiquettes '0' et '1' de la colonne 'admit' seront utilisées.
handles, legend_labels_auto = scatter_orig.legend_elements(prop="colors", num=[0,1]) # num spécifie les valeurs à légender
# Personnalisation des étiquettes pour la légende
legend_custom_labels = ['Non admis (0)', 'Admis (1)']
if len(handles) == len(legend_custom_labels): # S'assurer que le nombre d'éléments correspond
    fig.legend(handles, legend_custom_labels, loc='upper center', ncol=2, fontsize=12, bbox_to_anchor=(0.5, 0.98))
else: # Fallback si le nombre d'éléments ne correspond pas (par ex. si une classe manque dans les données visibles)
    fig.legend(handles, legend_labels_auto, loc='upper center', ncol=2, fontsize=12, bbox_to_anchor=(0.5, 0.98))


plt.tight_layout(rect=[0, 0, 1, 0.95]) # Ajuster pour laisser de la place à la légende en haut
plt.show()

print("\n--- Fin de la mise à l'échelle des caractéristiques ---")
print("\nAperçu de df_scaled (après Standardisation):")
print(df_scaled.head())
print("\nAperçu de df_min_max (après Normalisation Min-Max):")
print(df_min_max.head())
Après la standardisation (graphique de droite) : Les deux variables, gre et gpa, sont transformées pour avoir une moyenne proche de 0 et un écart-type de 1. Leurs échelles sont maintenant comparables (allant approximativement de -3 à +2 sur l’axe des valeurs standardisées), bien que la forme relative de leurs distributions (y compris la présence d’éventuelles valeurs aberrantes) soit préservée.

POUR ALLER PLUS LOIN : QUELLE MISE À L’ÉCHELLE CHOISIR ?

  • StandardScaler (Z-score) : idéal quand la distribution est approximativement normale
  • MinMaxScaler : utile quand vous avez besoin de valeurs positives dans [0,1]
  • RobustScaler : préférable en présence de valeurs aberrantes
  • MaxAbsScaler : adapté aux données parcimonieuses (beaucoup de zéros)

Pour la régression logistique, StandardScaler est généralement le choix le plus sûr.

Traitement des variables catégorielles

Les modèles linéaires comme la régression logistique ne peuvent pas traiter directement les variables catégorielles. Elles doivent être transformées en représentation numérique.

# Exploration de la variable catégorielle 'prestige'
print("Valeurs uniques de prestige:", df['prestige'].unique())
print("Nombre de valeurs uniques:", df['prestige'].nunique())

# 1. Encodage One-Hot
pd.get_dummies(df[['prestige']], prefix=['prestige']).head()

# 2. Encodage One-Hot avec suppression d'une colonne (évite la colinéarité)
pd.get_dummies(df[['prestige']], prefix=['prestige'], drop_first=True).head()

# 3. Encodage ordinal (si pertinent pour une variable ordinale comme prestige)
from sklearn.preprocessing import OrdinalEncoder
ordinal_encoder = OrdinalEncoder()
df_copy = df.copy()
df_copy['prestige_ordinal'] = ordinal_encoder.fit_transform(df[['prestige']])
df_copy[['prestige', 'prestige_ordinal']].head(10)

# Visualisation de l'impact des différents encodages sur l'admission
fig, axes = plt.subplots(1, 3, figsize=(18, 5))

# Original (traité comme catégoriel)
sns.barplot(x='prestige', y='admit', data=df, ax=axes[0])
axes[0].set_title('Variable originale', fontsize=14)
axes[0].set_xlabel('Niveau de prestige')
axes[0].set_ylabel('Taux d\'admission')

# Encodage ordinal
sns.barplot(x='prestige_ordinal', y='admit', data=df_copy, ax=axes[1])
axes[1].set_title('Encodage ordinal', fontsize=14)
axes[1].set_xlabel('Prestige encodé ordinalement')
axes[1].set_ylabel('Taux d\'admission')

# One-Hot (visualisation partielle)
dummies = pd.get_dummies(df[['prestige']], prefix=['prestige'])
df_dummies = pd.concat([df[['admit']], dummies], axis=1)
melted = pd.melt(df_dummies, id_vars=['admit'], var_name='prestige_dummy', value_name='is_category')
filtered = melted[melted['is_category'] == 1]
sns.barplot(x='prestige_dummy', y='admit', data=filtered, ax=axes[2])
axes[2].set_title('One-Hot (moyenne par catégorie)', fontsize=14)
axes[2].set_xlabel('Variables dummy de prestige')
axes[2].set_ylabel('Taux d\'admission')
axes[2].set_xticklabels(axes[2].get_xticklabels(), rotation=45)

plt.tight_layout()
plt.show()

ASTUCE PRATIQUE
Pour les variables catégorielles ordinales (comme un niveau de prestige), les deux approches sont valides :

  • Encodage ordinal : préserve l’ordre mais suppose une distance égale entre niveaux
  • Encodage One-Hot : ne suppose pas de relation d’ordre, mais crée plus de variables

Si la relation avec la variable cible est clairement monotone, l’encodage ordinal peut être préférable.

Discrétisation des variables continues

La discrétisation peut aider à capturer des relations non linéaires entre les variables et la cible.

# Discrétisation du score GRE
df_binned = df.copy()

# Définir les bins et labels
gre_bins = [min(df['gre'])-1, 500, 600, 700, max(df['gre'])+1]
gre_labels = ['faible', 'moyen', 'bon', 'excellent']

# Créer la variable discrétisée
df_binned['gre_cat'] = pd.cut(df['gre'], bins=gre_bins, labels=gre_labels)
print(df_binned[['gre', 'gre_cat']].head(10))

# Graphique montrant la relation entre la variable discrétisée et l'admission
plt.figure(figsize=(12, 5))

# Taux d'admission par catégorie
plt.subplot(1, 2, 1)
sns.barplot(x='gre_cat', y='admit', data=df_binned, order=gre_labels)
plt.title('Taux d\'admission par catégorie GRE', fontsize=14)
plt.xlabel('Catégorie GRE')
plt.ylabel('Taux d\'admission')

# Distribution des catégories
plt.subplot(1, 2, 2)
sns.countplot(x='gre_cat', data=df_binned, order=gre_labels)
plt.title('Nombre d\'étudiants par catégorie GRE', fontsize=14)
plt.xlabel('Catégorie GRE')
plt.ylabel('Nombre d\'étudiants')

plt.tight_layout()
plt.show()

# Comparaison: relation linéaire vs non-linéaire après discrétisation
fig, axes = plt.subplots(1, 2, figsize=(15, 6))

# Relation continue
sns.regplot(x='gre', y='admit', data=df, logistic=True, ax=axes[0])
axes[0].set_title('Relation continue (régression logistique)', fontsize=14)
axes[0].set_xlabel('Score GRE')
axes[0].set_ylabel('Probabilité d\'admission')
axes[0].grid(True, alpha=0.3)

# Relation après discrétisation
gre_cat_dummies = pd.get_dummies(df_binned['gre_cat'], prefix='gre')
X_binned = pd.concat([df_binned[['gre']], gre_cat_dummies], axis=1)
X_binned_sorted = X_binned.sort_values('gre')
from sklearn.linear_model import LogisticRegression
model = LogisticRegression()
model.fit(gre_cat_dummies, df_binned['admit'])
X_binned_sorted['pred_prob'] = model.predict_proba(X_binned_sorted[gre_cat_dummies.columns])[:, 1]

axes[1].scatter(X_binned_sorted['gre'], X_binned_sorted['admit'], alpha=0.3, c='blue')
axes[1].plot(X_binned_sorted['gre'], X_binned_sorted['pred_prob'], 'r-', linewidth=2)
axes[1].set_title('Relation après discrétisation', fontsize=14)
axes[1].set_xlabel('Score GRE')
axes[1].set_ylabel('Probabilité d\'admission')
axes[1].grid(True, alpha=0.3)

# Ajouter des lignes verticales pour les limites des bins
for bin_edge in gre_bins[1:-1]:
    axes[1].axvline(x=bin_edge, color='green', linestyle='--', alpha=0.7)

plt.tight_layout()
plt.show()

POUR ALLER PLUS LOIN : MÉTHODES DE DISCRÉTISATION

  • Discrétisation à largeur égale : intervalles de même taille
  • Discrétisation à fréquence égale (quantiles) : même nombre d’observations par intervalle
  • Discrétisation basée sur l’entropie : maximise la pureté des intervalles par rapport à la variable cible

La discrétisation supervisée (tenant compte de la variable cible) est souvent plus efficace pour la classification.

Division en ensembles d’entraînement et de test

Une bonne pratique essentielle est de diviser les données en ensembles d’entraînement et de test pour évaluer correctement les performances du modèle.

from sklearn.model_selection import train_test_split

# Préparation des données
X = df_binned[['gpa', 'prestige']]  # Caractéristiques
X = pd.concat([X, pd.get_dummies(df_binned['gre_cat'], prefix='gre', drop_first=True)], axis=1)
y = df_binned['admit']  # Variable cible

print("Caractéristiques utilisées:", X.columns.tolist())
print("Forme de X:", X.shape)
print("Forme de y:", y.shape)

# Division des données avec stratification sur y
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.25, random_state=42, stratify=y)

print("\nTailles des ensembles:")
print(f"Entraînement: {X_train.shape[0]} exemples ({X_train.shape[0]/X.shape[0]*100:.1f}%)")
print(f"Test: {X_test.shape[0]} exemples ({X_test.shape[0]/X.shape[0]*100:.1f}%)")

# Vérification de la distribution de la variable cible dans les deux ensembles
train_dist = y_train.value_counts(normalize=True)
test_dist = y_test.value_counts(normalize=True)

print("\nDistribution de la variable cible:")
print("Entraînement:", dict(train_dist.round(3)))
print("Test:", dict(test_dist.round(3)))

# Visualisation de la division
plt.figure(figsize=(12, 5))

plt.subplot(1, 2, 1)
plt.pie(y_train.value_counts(), labels=['Non admis', 'Admis'], autopct='%1.1f%%',
       colors=['lightcoral', 'lightgreen'], explode=(0, 0.1))
plt.title('Distribution dans l\'ensemble d\'entraînement', fontsize=14)

plt.subplot(1, 2, 2)
plt.pie(y_test.value_counts(), labels=['Non admis', 'Admis'], autopct='%1.1f%%',
       colors=['lightcoral', 'lightgreen'], explode=(0, 0.1))
plt.title('Distribution dans l\'ensemble de test', fontsize=14)

plt.tight_layout()
plt.show()

CONSEIL : STRATIFICATION ET TAILLE DE TEST

  • Utilisez stratify=y pour maintenir la même distribution de classes dans les ensembles d’entraînement et de test
  • Une taille d’ensemble de test typique est de 20-30% des données
  • Pour les petits jeux de données, considérez la validation croisée plutôt qu’une simple division
  • Pour les données temporelles, respectez l’ordre chronologique (les données futures ne doivent pas servir à prédire le passé)
# Illustration de l'importance de la stratification
from sklearn.model_selection import train_test_split

# Créer un jeu de données très déséquilibré pour illustrer
np.random.seed(42)
X_imbal = np.random.randn(1000, 2)
y_imbal = np.zeros(1000)
y_imbal[:50] = 1  # Seulement 5% de classe positive

# Division sans stratification
X_train1, X_test1, y_train1, y_test1 = train_test_split(
    X_imbal, y_imbal, test_size=0.3, random_state=0)

# Division avec stratification
X_train2, X_test2, y_train2, y_test2 = train_test_split(
    X_imbal, y_imbal, test_size=0.3, random_state=0, stratify=y_imbal)

# Comparaison des distributions
print("Sans stratification:")
print(f"Entraînement: {sum(y_train1)/len(y_train1)*100:.1f}% de classe positive")
print(f"Test: {sum(y_test1)/len(y_test1)*100:.1f}% de classe positive")

print("\nAvec stratification:")
print(f"Entraînement: {sum(y_train2)/len(y_train2)*100:.1f}% de classe positive")
print(f"Test: {sum(y_test2)/len(y_test2)*100:.1f}% de classe positive")

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

plt.subplot(2, 2, 1)
plt.pie([len(y_train1) - sum(y_train1), sum(y_train1)], 
        labels=['Négatif', 'Positif'], autopct='%1.1f%%')
plt.title('Entraînement sans stratification', fontsize=14)

plt.subplot(2, 2, 2)
plt.pie([len(y_test1) - sum(y_test1), sum(y_test1)], 
        labels=['Négatif', 'Positif'], autopct='%1.1f%%')
plt.title('Test sans stratification', fontsize=14)

plt.subplot(2, 2, 3)
plt.pie([len(y_train2) - sum(y_train2), sum(y_train2)], 
        labels=['Négatif', 'Positif'], autopct='%1.1f%%')
plt.title('Entraînement avec stratification', fontsize=14)

plt.subplot(2, 2, 4)
plt.pie([len(y_test2) - sum(y_test2), sum(y_test2)], 
        labels=['Négatif', 'Positif'], autopct='%1.1f%%')
plt.title('Test avec stratification', fontsize=14)

plt.tight_layout()
plt.show()

Implémentation et Entraînement avec Scikit-Learn

Modèle de base et paramètres principaux

Scikit-learn offre une implémentation robuste et flexible de la régression logistique à travers la classe LogisticRegression.

from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix

# Modèle de base avec paramètres par défaut
model = LogisticRegression(random_state=42)

# Entraînement du modèle
model.fit(X_train, y_train)

# Paramètres appris
print("Intercept (biais):", model.intercept_[0])
print("\nCoefficients:")
coef_df = pd.DataFrame({
    'Caractéristique': X_train.columns,
    'Coefficient': model.coef_[0]
}).sort_values('Coefficient', ascending=False)
print(coef_df)

# Prédictions sur l'ensemble de test
y_pred = model.predict(X_test)
y_prob = model.predict_proba(X_test)[:, 1]  # Probabilités pour la classe positive

# Évaluation de base
accuracy = accuracy_score(y_test, y_pred)
print(f"\nPrécision du modèle: {accuracy:.4f}")

# Matrice de confusion
cm = confusion_matrix(y_test, y_pred)
print("\nMatrice de confusion:")
print(cm)

# Rapport de classification
print("\nRapport de classification:")
print(classification_report(y_test, y_pred))

Les principaux paramètres de LogisticRegression sont:

# Présentation des paramètres les plus importants et leur influence
param_examples = {
    'Régularisation faible': LogisticRegression(C=10.0, random_state=42),
    'Régularisation forte': LogisticRegression(C=0.1, random_state=42),
    'Régularisation L1': LogisticRegression(penalty='l1', solver='liblinear', random_state=42),
    'Classes pondérées': LogisticRegression(class_weight='balanced', random_state=42),
    'Solveur rapide': LogisticRegression(solver='sag', random_state=42),
    'Itérations accrues': LogisticRegression(max_iter=1000, random_state=42)
}

# Comparaison des performances
results = {}
for name, model in param_examples.items():
    model.fit(X_train, y_train)
    y_pred = model.predict(X_test)
    accuracy = accuracy_score(y_test, y_pred)
    results[name] = {
        'Précision': accuracy,
        'Coefficients': model.coef_[0]
    }

# Affichage des précisions
print("Comparaison des précisions:")
precision_df = pd.DataFrame({name: {'Précision': results[name]['Précision']} 
                         for name in results}).T
print(precision_df)

# Visualisation des coefficients des différents modèles
coef_df = pd.DataFrame({name: results[name]['Coefficients'] for name in results}, 
                     index=X_train.columns)

plt.figure(figsize=(12, 8))
sns.heatmap(coef_df, annot=True, cmap='coolwarm', center=0, fmt='.2f')
plt.title('Comparaison des coefficients selon les paramètres', fontsize=16)
plt.tight_layout()
plt.show()

TABLEAU DES PARAMÈTRES CLÉS DE LOGISTICREGRESSION

ParamètreDescriptionValeurs typiquesImpact
CInverse de la force de régularisation0.01, 0.1, 1.0, 10.0, 100.0Plus C est petit, plus la régularisation est forte
penaltyType de régularisation‘l1’, ‘l2’, ‘elasticnet’, ‘none’L1 favorise la parcimonie, L2 est plus stable
solverAlgorithme d’optimisation‘lbfgs’, ‘liblinear’, ‘newton-cg’, ‘sag’, ‘saga’Dépend de la taille des données et du type de pénalité
max_iterNombre maximal d’itérations100, 1000, 10000Augmenter si l’algorithme ne converge pas
class_weightPoids des classesNone, ‘balanced’, dict‘balanced’ ajuste automatiquement les poids pour les classes déséquilibrées
random_stateGraine aléatoireEntier (ex: 42)Assure la reproductibilité des résultats
multi_classStratégie multi-classes‘auto’, ‘ovr’, ‘multinomial’‘ovr’ pour one-vs-rest, ‘multinomial’ pour approche multinomiale

POUR ALLER PLUS LOIN : CHOIX DU SOLVEUR
Le choix du solveur dépend de plusieurs facteurs :

  • liblinear : efficace pour petits jeux de données, supporte L1
  • lbfgs : rapide pour données de taille moyenne à grande
  • newton-cg : précis mais plus lent, pour données de taille moyenne
  • sag/saga : très efficaces pour grandes données, saga supporte tous les types de pénalités

Si vous n’êtes pas sûr, ‘lbfgs’ (par défaut) est généralement un bon choix.

Compréhension et interprétation des coefficients

L’un des grands avantages de la régression logistique est l’interprétabilité directe des coefficients.

# Calcul et visualisation des odds ratios
model = LogisticRegression(random_state=42)
model.fit(X_train, y_train)

# Coefficients et odds ratios
coef_df = pd.DataFrame({
    'Caractéristique': X_train.columns,
    'Coefficient': model.coef_[0],
    'Odds Ratio': np.exp(model.coef_[0])
}).sort_values('Coefficient', ascending=False)

# Affichage
print("Coefficients et odds ratios:")
print(coef_df)

# Visualisation graphique
plt.figure(figsize=(12, 8))
plt.subplot(2, 1, 1)
plt.barh(coef_df['Caractéristique'], coef_df['Coefficient'])
plt.axvline(x=0, color='r', linestyle='--')
plt.xlabel('Coefficient')
plt.title('Coefficients du modèle', fontsize=14)
plt.grid(True, alpha=0.3)

plt.subplot(2, 1, 2)
plt.barh(coef_df['Caractéristique'], coef_df['Odds Ratio'])
plt.axvline(x=1, color='r', linestyle='--')
plt.xlabel('Odds Ratio (rapport de cotes)')
plt.title('Odds Ratios du modèle', fontsize=14)
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

# Exemple d'interprétation détaillée
for index, row in coef_df.iterrows():
    feature = row['Caractéristique']
    coef = row['Coefficient']
    or_value = row['Odds Ratio']
    
    if coef > 0:
        direction = "augmente"
    else:
        direction = "diminue"
    
    if feature == 'gpa':
        print(f"Une augmentation d'une unité standardisée de GPA {direction} les chances d'admission "
              f"de {abs(or_value - 1)*100:.1f}%.")
    elif 'prestige' in feature:
        level = feature.split('_')[-1]
        print(f"Être dans la catégorie de prestige {level} {direction} les chances d'admission "
              f"de {abs(or_value - 1)*100:.1f}% par rapport à la catégorie de référence.")
    elif 'gre' in feature:
        category = feature.split('_')[-1]
        print(f"Avoir un score GRE '{category}' {direction} les chances d'admission "
              f"de {abs(or_value - 1)*100:.1f}% par rapport à la catégorie de référence.")

RAPPEL : INTERPRÉTATION DES ODDS RATIOS

  • OR = 1 : La variable n’a pas d’effet sur la probabilité
  • OR > 1 : La variable augmente la probabilité (ex: OR=2 → chances multipliées par 2)
  • OR < 1 : La variable diminue la probabilité (ex: OR=0.5 → chances divisées par 2)

Les odds ratios sont plus faciles à interpréter que les coefficients bruts.

# Exemple concrète d'interprétation pour un candidat spécifique
# Supposons un nouveau candidat avec ces caractéristiques
new_candidate = pd.DataFrame({
    'gpa': [3.5],
    'prestige': [2],
    'gre_cat': ['bon']
})

# Transformation des données pour correspondre au format du modèle
new_X = pd.DataFrame({
    'gpa': [3.5],
    'prestige': [2]
})
# One-hot encoding de gre_cat
gre_dummies = pd.get_dummies(new_candidate['gre_cat'], prefix='gre', drop_first=True)
# S'assurer que toutes les colonnes nécessaires sont présentes
for col in X_train.columns:
    if col not in new_X.columns and col not in gre_dummies.columns:
        gre_dummies[col] = 0
new_X = pd.concat([new_X, gre_dummies], axis=1)
new_X = new_X[X_train.columns]  # Réorganiser les colonnes comme dans X_train

# Obtenir la probabilité d'admission
prob = model.predict_proba(new_X)[0, 1]
print(f"Probabilité d'admission pour ce candidat: {prob:.2f} ({prob*100:.1f}%)")

# Explication détaillée de la prédiction
print("\nContributions à la prédiction:")
z = model.intercept_[0]  # Partir du biais
print(f"Biais initial (intercept): {z:.4f}")

feature_contributions = []
for i, col in enumerate(X_train.columns):
    contribution = model.coef_[0, i] * new_X.iloc[0, i]
    z += contribution
    feature_contributions.append({
        'Feature': col,
        'Value': new_X.iloc[0, i],
        'Coefficient': model.coef_[0, i],
        'Contribution': contribution
    })

contrib_df = pd.DataFrame(feature_contributions)
contrib_df = contrib_df.sort_values('Contribution', ascending=False)

# Afficher les contributions
for i, row in contrib_df.iterrows():
    print(f"• {row['Feature']} = {row['Value']:.2f} × coefficient {row['Coefficient']:.4f} → contribution: {row['Contribution']:.4f}")

# Score logit final et conversion en probabilité
print(f"\nScore logit final (z): {z:.4f}")
probability = 1 / (1 + np.exp(-z))
print(f"Probabilité après transformation sigmoïde: {probability:.4f}")

# Visualisation des contributions
plt.figure(figsize=(10, 6))
plt.barh(contrib_df['Feature'], contrib_df['Contribution'])
plt.axvline(x=0, color='r', linestyle='--')
plt.xlabel('Contribution au score logit')
plt.title('Contributions des caractéristiques à la prédiction', fontsize=14)
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

Construction d’un pipeline complet

Pour une approche plus robuste et reproductible, scikit-learn permet de créer des pipelines qui intègrent prétraitement et modélisation.

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

# Revenons aux données brutes pour montrer tout le processus
raw_df = pd.read_csv('admissions.csv')

# Définition des colonnes numériques et catégorielles
numeric_features = ['gre', 'gpa']
categorical_features = ['prestige']

# Prétraitement pour les colonnes numériques
numeric_transformer = Pipeline(steps=[
    ('imputer', SimpleImputer(strategy='median')),
    ('scaler', StandardScaler())
])

# Prétraitement pour les colonnes catégorielles
categorical_transformer = Pipeline(steps=[
    ('imputer', SimpleImputer(strategy='most_frequent')),
    ('onehot', OneHotEncoder(drop='first', handle_unknown='ignore'))
])

# Combinaison des prétraitements
preprocessor = ColumnTransformer(
    transformers=[
        ('num', numeric_transformer, numeric_features),
        ('cat', categorical_transformer, categorical_features)
    ])

# Création du pipeline complet
pipe = Pipeline(steps=[
    ('preprocessor', preprocessor),
    ('classifier', LogisticRegression(random_state=42))
])

# Division des données brutes
X_raw = raw_df.drop('admit', axis=1)
y_raw = raw_df['admit']

X_train_raw, X_test_raw, y_train_raw, y_test_raw = train_test_split(
    X_raw, y_raw, test_size=0.25, random_state=42, stratify=y_raw)

# Entraînement du pipeline
pipe.fit(X_train_raw, y_train_raw)

# Évaluation
y_pred_raw = pipe.predict(X_test_raw)
y_prob_raw = pipe.predict_proba(X_test_raw)[:, 1]

accuracy = accuracy_score(y_test_raw, y_pred_raw)
print(f"Précision du pipeline: {accuracy:.4f}")
print("\nRapport de classification:")
print(classification_report(y_test_raw, y_pred_raw))

# Visualisation de la matrice de confusion
plt.figure(figsize=(8, 6))
cm = confusion_matrix(y_test_raw, y_pred_raw)
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',
            xticklabels=['Non admis', 'Admis'],
            yticklabels=['Non admis', 'Admis'])
plt.xlabel('Prédiction')
plt.ylabel('Réalité')
plt.title('Matrice de confusion du pipeline', fontsize=14)
plt.tight_layout()
plt.show()

AVANTAGES DES PIPELINES

  1. Prévention des fuites de données : le prétraitement est appliqué correctement à chaque ensemble
  2. Reproductibilité : tout le flux de travail est encapsulé dans un seul objet
  3. Simplicité de déploiement : un seul objet à sauvegarder et charger
  4. Optimisation intégrée : possibilité d’optimiser conjointement prétraitement et modèle

Construisez toujours vos modèles avec des pipelines dans un environnement de production.

# Exemple d'optimisation des hyperparamètres du pipeline complet
from sklearn.model_selection import GridSearchCV

# Définition de la grille de paramètres
param_grid = {
    # Paramètres de prétraitement
    'preprocessor__num__imputer__strategy': ['mean', 'median'],
    'preprocessor__num__scaler': [StandardScaler(), MinMaxScaler()],
    
    # Paramètres du modèle
    'classifier__C': [0.1, 1.0, 10.0],
    'classifier__class_weight': [None, 'balanced'],
}

# Recherche par grille avec validation croisée
grid_search = GridSearchCV(
    pipe, param_grid, cv=5, scoring='accuracy', n_jobs=-1, verbose=1
)

# Exemple sur un petit sous-ensemble pour illustrer (dans la pratique, utilisez tout)
grid_search.fit(X_train_raw.sample(100, random_state=42), 
               y_train_raw.loc[X_train_raw.sample(100, random_state=42).index])

print("Meilleurs paramètres:")
print(grid_search.best_params_)
print(f"Meilleur score CV: {grid_search.best_score_:.4f}")

# Création d'un pipeline avec les meilleurs paramètres
best_pipe = grid_search.best_estimator_

# Réentraînement sur toutes les données d'entraînement
best_pipe.fit(X_train_raw, y_train_raw)

# Évaluation
y_pred_best = best_pipe.predict(X_test_raw)
best_accuracy = accuracy_score(y_test_raw, y_pred_best)
print(f"Précision du meilleur pipeline: {best_accuracy:.4f}")

Évaluation et Interprétation du Modèle

Métriques d’évaluation pour la classification binaire

L’évaluation d’un modèle de classification binaire va bien au-delà de la simple précision (accuracy).

from sklearn.metrics import (confusion_matrix, accuracy_score, precision_score, 
                           recall_score, f1_score, roc_auc_score, precision_recall_curve,
                           roc_curve, average_precision_score)

# Utilisons notre modèle précédent et ses prédictions
model = LogisticRegression(random_state=42)
model.fit(X_train, y_train)
y_pred = model.predict(X_test)
y_prob = model.predict_proba(X_test)[:, 1]

# Matrice de confusion
cm = confusion_matrix(y_test, y_pred)
tn, fp, fn, tp = cm.ravel()

# Calcul des métriques principales
accuracy = accuracy_score(y_test, y_pred)
precision = precision_score(y_test, y_pred)
recall = recall_score(y_test, y_pred)
specificity = tn / (tn + fp)
f1 = f1_score(y_test, y_pred)
auc = roc_auc_score(y_test, y_prob)
ap = average_precision_score(y_test, y_prob)

# Affichage des métriques dans un tableau explicatif
metrics_df = pd.DataFrame({
    'Métrique': ['Précision (Accuracy)', 'Précision (Precision)', 'Rappel (Recall/Sensibilité)', 
                'Spécificité', 'Score F1', 'AUC-ROC', 'Précision moyenne (AP)'],
    'Valeur': [accuracy, precision, recall, specificity, f1, auc, ap],
    'Formule': ['(TP + TN) / (TP + TN + FP + FN)', 'TP / (TP + FP)', 'TP / (TP + FN)', 
               'TN / (TN + FP)', '2 * (precision * recall) / (precision + recall)', 
               'Aire sous la courbe ROC', 'Aire sous la courbe Précision-Rappel'],
    'Description': [
        'Proportion de prédictions correctes',
        'Proportion de vrais positifs parmi les prédictions positives',
        'Proportion de vrais positifs correctement identifiés',
        'Proportion de vrais négatifs correctement identifiés',
        'Moyenne harmonique de la précision et du rappel',
        'Capacité à distinguer les classes (0.5=aléatoire, 1=parfait)',
        'Résumé de la courbe précision-rappel'
    ]
})

print("Matrice de confusion:")
print(f"[[TN={tn}, FP={fp}], [FN={fn}, TP={tp}]]")
print("\nMétriques d'évaluation:")
pd.set_option('display.max_colwidth', None)
print(metrics_df.to_string(index=False))

# Visualisation de la matrice de confusion
plt.figure(figsize=(10, 8))
plt.subplot(1, 1, 1)
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',
            xticklabels=['Non admis', 'Admis'],
            yticklabels=['Non admis', 'Admis'])

# Ajout des métriques sur le graphique
plt.xlabel('Prédiction')
plt.ylabel('Réalité')
plt.title('Matrice de confusion', fontsize=16)

# Annotations des métriques
plt.text(0.5, -0.1, f"Précision = {accuracy:.3f}", transform=plt.gca().transAxes, ha='center', fontsize=12)
plt.text(0.5, -0.15, f"Sensibilité = {recall:.3f}, Spécificité = {specificity:.3f}", 
         transform=plt.gca().transAxes, ha='center', fontsize=12)
plt.text(0.5, -0.2, f"Précision = {precision:.3f}, F1 = {f1:.3f}", 
         transform=plt.gca().transAxes, ha='center', fontsize=12)

plt.tight_layout()
plt.subplots_adjust(bottom=0.25)
plt.show()

QUELLE MÉTRIQUE CHOISIR ?
Le choix dépend du contexte du problème:

  • Accuracy : bonne métrique générale si les classes sont équilibrées
  • Precision : importante quand le coût des faux positifs est élevé (ex: spam détection)
  • Recall/Sensibilité : cruciale quand les faux négatifs sont coûteux (ex: détection de maladies)
  • F1-score : utile quand il faut équilibrer précision et rappel
  • AUC-ROC : excellente pour comparer des modèles indépendamment du seuil

Dans le doute, regardez plusieurs métriques pour une évaluation complète.

Courbe ROC et courbe de précision-rappel

Ces courbes permettent d’évaluer les performances du modèle à différents seuils de décision.

# Calcul des points pour les courbes
fpr, tpr, thresholds_roc = roc_curve(y_test, y_prob)
precision_curve, recall_curve, thresholds_pr = precision_recall_curve(y_test, y_prob)

# Calcul des aires sous les courbes
roc_auc = auc(fpr, tpr)
pr_auc = auc(recall_curve, precision_curve)

# Visualisation côte à côte
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(18, 7))

# Courbe ROC
ax1.plot(fpr, tpr, 'b-', linewidth=2, label=f'AUC = {roc_auc:.3f}')
ax1.plot([0, 1], [0, 1], 'k--', label='Aléatoire (AUC = 0.5)')
ax1.set_xlim([0.0, 1.0])
ax1.set_ylim([0.0, 1.05])
ax1.set_xlabel('Taux de faux positifs (1 - Spécificité)', fontsize=12)
ax1.set_ylabel('Taux de vrais positifs (Sensibilité)', fontsize=12)
ax1.set_title('Courbe ROC', fontsize=16)
ax1.legend(loc="lower right", fontsize=12)
ax1.grid(True, alpha=0.3)

# Annotations pédagogiques sur la courbe ROC
ax1.plot(0, 1, 'go', markersize=10)
ax1.text(0.05, 0.9, 'Classifieur parfait', fontsize=10)
ax1.plot(1, 0, 'ro', markersize=10)
ax1.text(0.8, 0.1, 'Classifieur inverse', fontsize=10)
ax1.plot(0.5, 0.5, 'yo', markersize=10)
ax1.text(0.4, 0.4, 'Classifieur aléatoire', fontsize=10)

# Courbe Précision-Rappel
ax2.plot(recall_curve, precision_curve, 'g-', linewidth=2, label=f'AUC = {pr_auc:.3f}')
ax2.axhline(y=sum(y_test)/len(y_test), color='k', linestyle='--', 
           label=f'Aléatoire (AUC = {sum(y_test)/len(y_test):.3f})')
ax2.set_xlim([0.0, 1.0])
ax2.set_ylim([0.0, 1.05])
ax2.set_xlabel('Rappel', fontsize=12)
ax2.set_ylabel('Précision', fontsize=12)
ax2.set_title('Courbe Précision-Rappel', fontsize=16)
ax2.legend(loc="lower left", fontsize=12)
ax2.grid(True, alpha=0.3)

# Visualisation des points correspondant à différents seuils
threshold_points = [0.2, 0.5, 0.8]
for threshold in threshold_points:
    # Pour la courbe ROC
    idx_roc = np.argmin(np.abs(thresholds_roc - threshold))
    ax1.plot(fpr[idx_roc], tpr[idx_roc], 'ro', markersize=8)
    ax1.text(fpr[idx_roc]+0.02, tpr[idx_roc]-0.06, f'Seuil={threshold}', fontsize=10)
    
    # Pour la courbe Précision-Rappel
    y_pred_threshold = (y_prob >= threshold).astype(int)
    prec = precision_score(y_test, y_pred_threshold)
    rec = recall_score(y_test, y_pred_threshold)
    ax2.plot(rec, prec, 'ro', markersize=8)
    ax2.text(rec+0.02, prec-0.06, f'Seuil={threshold}', fontsize=10)

plt.tight_layout()
plt.show()

POUR ALLER PLUS LOIN : COURBE ROC VS PRÉCISION-RAPPEL

  • La courbe ROC peut donner une vision trop optimiste pour les données très déséquilibrées
  • La courbe de précision-rappel est plus informative dans ces cas
  • La courbe ROC tient compte des vrais négatifs (TN), pas la courbe PR
  • Règle pratique : si vous vous souciez plus de la classe positive (minoritaire), utilisez la courbe PR

Regardez les deux courbes pour une évaluation complète dans les cas critiques.

Impact du seuil de décision

L’un des aspects les plus sous-estimés de la régression logistique est l’importance du seuil de décision.

# Évaluation en fonction du seuil
thresholds = np.arange(0.05, 1.0, 0.05)
results = []

for threshold in thresholds:
    y_pred_threshold = (y_prob >= threshold).astype(int)
    tn, fp, fn, tp = confusion_matrix(y_test, y_pred_threshold).ravel()
    
    # Calcul des métriques
    accuracy = (tp + tn) / (tp + tn + fp + fn)
    precision = tp / (tp + fp) if (tp + fp) > 0 else 0
    recall = tp / (tp + fn) if (tp + fn) > 0 else 0
    specificity = tn / (tn + fp) if (tn + fp) > 0 else 0
    f1 = 2 * (precision * recall) / (precision + recall) if (precision + recall) > 0 else 0
    
    results.append({
        'Seuil': threshold,
        'Exactitude': accuracy,
        'Précision': precision,
        'Rappel': recall,
        'Spécificité': specificity,
        'Score F1': f1,
        'TP': tp,
        'FP': fp,
        'TN': tn,
        'FN': fn
    })

# Création d'un DataFrame
threshold_df = pd.DataFrame(results)

# Visualisation des métriques en fonction du seuil
plt.figure(figsize=(14, 10))

# Graphique des métriques
plt.subplot(2, 1, 1)
plt.plot(threshold_df['Seuil'], threshold_df['Exactitude'], 'b-', label='Exactitude')
plt.plot(threshold_df['Seuil'], threshold_df['Précision'], 'g-', label='Précision')
plt.plot(threshold_df['Seuil'], threshold_df['Rappel'], 'r-', label='Rappel')
plt.plot(threshold_df['Seuil'], threshold_df['Spécificité'], 'c-', label='Spécificité')
plt.plot(threshold_df['Seuil'], threshold_df['Score F1'], 'y-', label='F1 Score')
plt.xlabel('Seuil de décision', fontsize=12)
plt.ylabel('Valeur', fontsize=12)
plt.title('Évolution des métriques en fonction du seuil', fontsize=16)
plt.legend(fontsize=12)
plt.grid(True, alpha=0.3)

# Graphique des effectifs TP, FP, TN, FN
plt.subplot(2, 1, 2)
plt.plot(threshold_df['Seuil'], threshold_df['TP'], 'g-', label='Vrais Positifs (TP)')
plt.plot(threshold_df['Seuil'], threshold_df['FP'], 'r-', label='Faux Positifs (FP)')
plt.plot(threshold_df['Seuil'], threshold_df['TN'], 'b-', label='Vrais Négatifs (TN)')
plt.plot(threshold_df['Seuil'], threshold_df['FN'], 'y-', label='Faux Négatifs (FN)')
plt.xlabel('Seuil de décision', fontsize=12)
plt.ylabel('Nombre d\'exemples', fontsize=12)
plt.title('Répartition des prédictions en fonction du seuil', fontsize=16)
plt.legend(fontsize=12)
plt.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

# Détermination du seuil optimal selon différents critères
optimal_thresholds = {
    'F1 maximal': threshold_df.loc[threshold_df['Score F1'].idxmax(), 'Seuil'],
    'Équilibre Sensibilité-Spécificité': threshold_df.loc[
        (threshold_df['Rappel'] - threshold_df['Spécificité']).abs().idxmin(), 'Seuil'
    ],
    'Précision > 0.8': threshold_df[threshold_df['Précision'] >= 0.8]['Seuil'].min()
        if any(threshold_df['Précision'] >= 0.8) else None,
    'Rappel > 0.8': threshold_df[threshold_df['Rappel'] >= 0.8]['Seuil'].max()
        if any(threshold_df['Rappel'] >= 0.8) else None
}

print("Seuils optimaux selon différents critères:")
for criterion, threshold in optimal_thresholds.items():
    if threshold is not None:
        idx = threshold_df['Seuil'].sub(threshold).abs().idxmin()
        row = threshold_df.loc[idx]
        print(f"{criterion}: {threshold:.2f} → F1={row['Score F1']:.3f}, "
              f"Précision={row['Précision']:.3f}, Rappel={row['Rappel']:.3f}")
    else:
        print(f"{criterion}: Aucun seuil ne satisfait ce critère")

CONSEILS POUR CHOISIR LE SEUIL OPTIMAL

  • Pour maximiser le F1-score : choisir le seuil avec le meilleur F1
  • Pour équilibrer sensibilité et spécificité : choisir le seuil où les deux courbes se croisent
  • Pour un système de filtrage (ex: spam) : privilégier un seuil avec haute spécificité
  • Pour un système de détection (ex: fraude) : privilégier un seuil avec haute sensibilité

Le seuil par défaut de 0.5 n’est pas toujours optimal !

# Application pratique : calibration d'un seuil pour un cas d'usage spécifique
# Exemple : système de détection de fraude
# Supposons qu'un faux négatif (FN) coûte 10 fois plus cher qu'un faux positif (FP)

# Fonction de coût personnalisée
def custom_cost(y_true, y_pred, fn_cost=10, fp_cost=1):
    tn, fp, fn, tp = confusion_matrix(y_true, y_pred).ravel()
    return fn_cost * fn + fp_cost * fp

# Évaluation du coût pour différents seuils
cost_results = []
for threshold in thresholds:
    y_pred_threshold = (y_prob >= threshold).astype(int)
    cost = custom_cost(y_test, y_pred_threshold)
    
    cost_results.append({
        'Seuil': threshold,
        'Coût total': cost
    })

cost_df = pd.DataFrame(cost_results)

# Détermination du seuil optimal selon le coût
optimal_cost_threshold = cost_df.loc[cost_df['Coût total'].idxmin(), 'Seuil']

# Visualisation
plt.figure(figsize=(10, 6))
plt.plot(cost_df['Seuil'], cost_df['Coût total'], 'r-', linewidth=2)
plt.axvline(x=optimal_cost_threshold, color='g', linestyle='--', 
           label=f'Seuil optimal: {optimal_cost_threshold:.2f}')
plt.xlabel('Seuil de décision', fontsize=12)
plt.ylabel('Coût total', fontsize=12)
plt.title('Coût total en fonction du seuil (FN coûte 10x plus que FP)', fontsize=16)
plt.legend(fontsize=12)
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

# Comparaison des performances avec le seuil optimal vs seuil par défaut
y_pred_default = (y_prob >= 0.5).astype(int)
y_pred_optimal = (y_prob >= optimal_cost_threshold).astype(int)

print("Comparaison des performances:")
print(f"Seuil par défaut (0.5): Coût = {custom_cost(y_test, y_pred_default)}")
print(f"Seuil optimal ({optimal_cost_threshold:.2f}): "
      f"Coût = {custom_cost(y_test, y_pred_optimal)}")

# Matrices de confusion
fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# Matrice avec seuil par défaut
cm_default = confusion_matrix(y_test, y_pred_default)
sns.heatmap(cm_default, annot=True, fmt='d', cmap='Blues',
           xticklabels=['Non admis', 'Admis'],
           yticklabels=['Non admis', 'Admis'], ax=axes[0])
axes[0].set_title(f'Seuil par défaut (0.5)', fontsize=14)

# Matrice avec seuil optimal
cm_optimal = confusion_matrix(y_test, y_pred_optimal)
sns.heatmap(cm_optimal, annot=True, fmt='d', cmap='Blues',
           xticklabels=['Non admis', 'Admis'],
           yticklabels=['Non admis', 'Admis'], ax=axes[1])
axes[1].set_title(f'Seuil optimal ({optimal_cost_threshold:.2f})', fontsize=14)

plt.tight_layout()
plt.show()

Techniques Avancées et Optimisation

Gestion des classes déséquilibrées

Les problèmes de classification avec des classes déséquilibrées sont très courants dans la pratique.

# Application pratique : calibration d'un seuil pour un cas d'usage spécifique
# Exemple : système de détection de fraude
# Supposons qu'un faux négatif (FN) coûte 10 fois plus cher qu'un faux positif (FP)

# Fonction de coût personnalisée
def custom_cost(y_true, y_pred, fn_cost=10, fp_cost=1):
    tn, fp, fn, tp = confusion_matrix(y_true, y_pred).ravel()
    return fn_cost * fn + fp_cost * fp

# Évaluation du coût pour différents seuils
cost_results = []
for threshold in thresholds:
    y_pred_threshold = (y_prob >= threshold).astype(int)
    cost = custom_cost(y_test, y_pred_threshold)
    
    cost_results.append({
        'Seuil': threshold,
        'Coût total': cost
    })

cost_df = pd.DataFrame(cost_results)

# Détermination du seuil optimal selon le coût
optimal_cost_threshold = cost_df.loc[cost_df['Coût total'].idxmin(), 'Seuil']

# Visualisation
plt.figure(figsize=(10, 6))
plt.plot(cost_df['Seuil'], cost_df['Coût total'], 'r-', linewidth=2)
plt.axvline(x=optimal_cost_threshold, color='g', linestyle='--', 
           label=f'Seuil optimal: {optimal_cost_threshold:.2f}')
plt.xlabel('Seuil de décision', fontsize=12)
plt.ylabel('Coût total', fontsize=12)
plt.title('Coût total en fonction du seuil (FN coûte 10x plus que FP)', fontsize=16)
plt.legend(fontsize=12)
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

# Comparaison des performances avec le seuil optimal vs seuil par défaut
y_pred_default = (y_prob >= 0.5).astype(int)
y_pred_optimal = (y_prob >= optimal_cost_threshold).astype(int)

print("Comparaison des performances:")
print(f"Seuil par défaut (0.5): Coût = {custom_cost(y_test, y_pred_default)}")
print(f"Seuil optimal ({optimal_cost_threshold:.2f}): "
      f"Coût = {custom_cost(y_test, y_pred_optimal)}")

# Matrices de confusion
fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# Matrice avec seuil par défaut
cm_default = confusion_matrix(y_test, y_pred_default)
sns.heatmap(cm_default, annot=True, fmt='d', cmap='Blues',
           xticklabels=['Non admis', 'Admis'],
           yticklabels=['Non admis', 'Admis'], ax=axes[0])
axes[0].set_title(f'Seuil par défaut (0.5)', fontsize=14)

# Matrice avec seuil optimal
cm_optimal = confusion_matrix(y_test, y_pred_optimal)
sns.heatmap(cm_optimal, annot=True, fmt='d', cmap='Blues',
           xticklabels=['Non admis', 'Admis'],
           yticklabels=['Non admis', 'Admis'], ax=axes[1])
axes[1].set_title(f'Seuil optimal ({optimal_cost_threshold:.2f})', fontsize=14)

plt.tight_layout()
plt.show()

TABLEAU COMPARATIF DES TECHNIQUES POUR CLASSES DÉSÉQUILIBRÉES

TechniqueDescriptionAvantagesInconvénientsQuand l’utiliser
Class weightsPénalise davantage les erreurs sur la classe minoritaireSimple, préserve tous les exemplesPeut ne pas suffire pour fort déséquilibrePremier essai, déséquilibre modéré
UndersamplingRéduit le nombre d’exemples de la classe majoritaireRapide, réduit la taille des donnéesPerte d’informationBeaucoup d’exemples majoritaires
OversamplingDuplique les exemples de la classe minoritaireConserve toute l’informationRisque de surapprentissageClasses très déséquilibrées
SMOTEGénère des exemples synthétiques de la classe minoritaireEfficace, évite le surapprentissageComplexe, sensible aux paramètresDéséquilibre important, peu d’exemples minoritaires
Seuil de décisionAjuste le seuil de classificationSimple, pas de modification des donnéesRequiert probabilités calibréesEn complément des autres techniques

POUR ALLER PLUS LOIN : APPROCHES HYBRIDES
Combiner plusieurs techniques peut donner de meilleurs résultats :

  • SMOTETomek : SMOTE + nettoyage des exemples difficiles
  • SMOTEENN : SMOTE + nettoyage par règle des plus proches voisins
  • Pipeline avec validation croisée : pour optimiser les paramètres des techniques de rééchantillonnage

La bibliothèque imbalanced-learn offre ces techniques avancées et s’intègre parfaitement à scikit-learn.

Optimisation des hyperparamètres

L’optimisation des hyperparamètres est cruciale pour obtenir les meilleures performances possibles.

from sklearn.model_selection import GridSearchCV, RandomizedSearchCV
from scipy.stats import loguniform

# Configuration pour un exemple rapide
param_grid = {
    'C': [0.001, 0.01, 0.1, 1, 10, 100],
    'penalty': ['l1', 'l2'],
    'solver': ['liblinear', 'saga'],
    'class_weight': [None, 'balanced']
}

# Recherche par grille
model = LogisticRegression(random_state=42, max_iter=1000)
grid_search = GridSearchCV(
    model, param_grid, cv=5, scoring='f1', n_jobs=-1, verbose=1
)
grid_search.fit(X_train, y_train)

print("Meilleurs paramètres (GridSearchCV):")
print(grid_search.best_params_)
print(f"Meilleur score CV: {grid_search.best_score_:.4f}")

# Recherche aléatoire avec distributions
param_distributions = {
    'C': loguniform(1e-3, 1e3),
    'penalty': ['l1', 'l2'],
    'solver': ['liblinear', 'saga'],
    'class_weight': [None, 'balanced']
}

random_search = RandomizedSearchCV(
    model, param_distributions, n_iter=20, cv=5, scoring='f1', n_jobs=-1, 
    random_state=42, verbose=1
)
random_search.fit(X_train, y_train)

print("\nMeilleurs paramètres (RandomizedSearchCV):")
print(random_search.best_params_)
print(f"Meilleur score CV: {random_search.best_score_:.4f}")

# Visualisation des résultats de la recherche
results = pd.DataFrame(grid_search.cv_results_)

# Moyenne des scores pour chaque valeur de C et penalty
pivot_table = pd.pivot_table(
    results, 
    values='mean_test_score', 
    index='param_C',
    columns=['param_penalty', 'param_class_weight'],
    aggfunc=np.mean
)

plt.figure(figsize=(14, 8))
sns.heatmap(pivot_table, annot=True, cmap='YlGnBu', fmt='.3f')
plt.title('Scores moyens par valeur de C, penalty et class_weight', fontsize=16)
plt.ylabel('Valeur de C (log scale)', fontsize=12)
plt.tight_layout()
plt.show()

# Courbes d'apprentissage pour le meilleur modèle
from sklearn.model_selection import learning_curve

best_model = grid_search.best_estimator_

train_sizes, train_scores, test_scores = learning_curve(
    best_model, X, y, cv=5, scoring='f1', 
    train_sizes=np.linspace(0.1, 1.0, 10), n_jobs=-1
)

train_mean = np.mean(train_scores, axis=1)
train_std = np.std(train_scores, axis=1)
test_mean = np.mean(test_scores, axis=1)
test_std = np.std(test_scores, axis=1)

plt.figure(figsize=(10, 6))
plt.plot(train_sizes, train_mean, 'o-', color='r', label='Score d\'entraînement')
plt.fill_between(train_sizes, train_mean - train_std, train_mean + train_std, alpha=0.1, color='r')
plt.plot(train_sizes, test_mean, 'o-', color='g', label='Score de validation')
plt.fill_between(train_sizes, test_mean - test_std, test_mean + test_std, alpha=0.1, color='g')
plt.xlabel('Nombre d\'exemples d\'entraînement', fontsize=12)
plt.ylabel('Score F1', fontsize=12)
plt.title('Courbes d\'apprentissage pour le meilleur modèle', fontsize=16)
plt.legend(loc='best', fontsize=12)
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

ASTUCES POUR L’OPTIMISATION DES HYPERPARAMÈTRES

  1. Exploration initiale : recherche aléatoire avec distribution large de paramètres
  2. Raffinement : recherche par grille autour des valeurs prometteuses
  3. Métrique adaptée : choisir une métrique alignée avec l’objectif métier
  4. Validation croisée : utiliser k-fold (k=5 ou 10) pour des estimations robustes
  5. Parallélisation : utiliser n_jobs=-1 pour accélérer la recherche

Processus itératif : optimisation → analyse → ajustement des paramètres → ré-optimisation

Validation croisée stratifiée

Pour une évaluation robuste, surtout avec des données limitées ou déséquilibrées, la validation croisée stratifiée est essentielle.

from sklearn.model_selection import cross_validate, StratifiedKFold, KFold

# Configuration de la validation croisée
cv_standard = KFold(n_splits=5, shuffle=True, random_state=42)
cv_stratified = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

# Modèle à évaluer
model = LogisticRegression(random_state=42)

# Métriques à calculer
scoring = {
    'accuracy': 'accuracy',
    'precision': 'precision',
    'recall': 'recall',
    'f1': 'f1',
    'roc_auc': 'roc_auc'
}

# Validation croisée standard vs stratifiée
results_standard = cross_validate(model, X, y, cv=cv_standard, scoring=scoring)
results_stratified = cross_validate(model, X, y, cv=cv_stratified, scoring=scoring)

# Comparaison des résultats
comparison = pd.DataFrame({
    'Standard - Moyenne': {metric: results_standard[f'test_{metric}'].mean() 
                          for metric in scoring},
    'Standard - Écart-type': {metric: results_standard[f'test_{metric}'].std() 
                             for metric in scoring},
    'Stratifiée - Moyenne': {metric: results_stratified[f'test_{metric}'].mean() 
                            for metric in scoring},
    'Stratifiée - Écart-type': {metric: results_stratified[f'test_{metric}'].std() 
                               for metric in scoring}
})

print("Comparaison validation croisée standard vs stratifiée:")
print(comparison)

# Visualisation des distributions des scores pour chaque pli
fig, axes = plt.subplots(2, 3, figsize=(18, 10))
axes = axes.flatten()

metrics = list(scoring.keys())
for i, metric in enumerate(metrics[:5]):  # Limité aux 5 premières métriques
    axes[i].boxplot([results_standard[f'test_{metric}'], results_stratified[f'test_{metric}']],
                   labels=['Standard', 'Stratifiée'])
    axes[i].set_title(f'Distribution de {metric}', fontsize=14)
    axes[i].set_ylabel('Score', fontsize=12)
    axes[i].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

POURQUOI UTILISER LA VALIDATION CROISÉE STRATIFIÉE ?

  • Maintient la même distribution de classes dans chaque pli
  • Réduit la variance des estimations de performance
  • Particulièrement importante pour les classes déséquilibrées
  • Fournit une estimation plus réaliste de la performance sur de nouvelles données

Utilisez toujours la validation croisée stratifiée pour les problèmes de classification.

Applications pratiques et cas d’Usage

Prédiction de risque de crédit

La régression logistique est l’un des algorithmes privilégiés dans le secteur financier pour l’évaluation des risques.

# Création d'un jeu de données synthétique d'évaluation de crédit
np.random.seed(42)
n_samples = 1000

# Variables explicatives
age = np.random.normal(35, 10, n_samples)  # Âge moyen de 35 ans
income = np.random.normal(50000, 15000, n_samples)  # Revenu moyen de 50000
debt_ratio = np.random.beta(2, 5, n_samples)  # Ratio d'endettement (entre 0 et 1)
credit_history = np.random.poisson(5, n_samples)  # Années d'historique de crédit

# Création d'une relation entre les variables et la probabilité de défaut
z = -2 + 0.02 * (30 - age) + 0.03 * (debt_ratio * 100) - 0.2 * credit_history - 0.00003 * income
prob_default = 1 / (1 + np.exp(-z))
default = (np.random.random(n_samples) < prob_default).astype(int)

# Création du DataFrame
credit_data = pd.DataFrame({
    'age': age,
    'income': income,
    'debt_ratio': debt_ratio,
    'credit_history': credit_history,
    'default': default
})

# Visualisation des relations
fig, axes = plt.subplots(2, 2, figsize=(15, 10))

# Age vs Default
axes[0, 0].scatter(credit_data['age'], credit_data['default'], 
                 alpha=0.3, c=credit_data['default'], cmap='coolwarm')
axes[0, 0].set_xlabel('Âge', fontsize=12)
axes[0, 0].set_ylabel('Défaut (0=non, 1=oui)', fontsize=12)
axes[0, 0].set_title('Relation entre âge et défaut de paiement', fontsize=14)
axes[0, 0].grid(True, alpha=0.3)

# Income vs Default
axes[0, 1].scatter(credit_data['income'], credit_data['default'], 
                 alpha=0.3, c=credit_data['default'], cmap='coolwarm')
axes[0, 1].set_xlabel('Revenu annuel (€)', fontsize=12)
axes[0, 1].set_ylabel('Défaut (0=non, 1=oui)', fontsize=12)
axes[0, 1].set_title('Relation entre revenu et défaut de paiement', fontsize=14)
axes[0, 1].grid(True, alpha=0.3)

# Debt Ratio vs Default
axes[1, 0].scatter(credit_data['debt_ratio'], credit_data['default'], 
                 alpha=0.3, c=credit_data['default'], cmap='coolwarm')
axes[1, 0].set_xlabel('Ratio d\'endettement', fontsize=12)
axes[1, 0].set_ylabel('Défaut (0=non, 1=oui)', fontsize=12)
axes[1, 0].set_title('Relation entre endettement et défaut de paiement', fontsize=14)
axes[1, 0].grid(True, alpha=0.3)

# Credit History vs Default
axes[1, 1].scatter(credit_data['credit_history'], credit_data['default'], 
                 alpha=0.3, c=credit_data['default'], cmap='coolwarm')
axes[1, 1].set_xlabel('Années d\'historique de crédit', fontsize=12)
axes[1, 1].set_ylabel('Défaut (0=non, 1=oui)', fontsize=12)
axes[1, 1].set_title('Relation entre historique et défaut de paiement', fontsize=14)
axes[1, 1].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

# Préparation des données
X_credit = credit_data.drop('default', axis=1)
y_credit = credit_data['default']

# Division en ensembles d'entraînement et de test
X_train_credit, X_test_credit, y_train_credit, y_test_credit = train_test_split(
    X_credit, y_credit, test_size=0.3, random_state=42, stratify=y_credit
)

# Prétraitement
scaler = StandardScaler()
X_train_credit_scaled = scaler.fit_transform(X_train_credit)
X_test_credit_scaled = scaler.transform(X_test_credit)

# Entraînement du modèle avec validation croisée
param_grid = {
    'C': [0.01, 0.1, 1, 10],
    'class_weight': [None, 'balanced']
}

grid_search = GridSearchCV(
    LogisticRegression(random_state=42),
    param_grid,
    cv=5,
    scoring='roc_auc',
    n_jobs=-1
)

grid_search.fit(X_train_credit_scaled, y_train_credit)
best_model_credit = grid_search.best_estimator_

# Évaluation
y_pred_credit = best_model_credit.predict(X_test_credit_scaled)
y_prob_credit = best_model_credit.predict_proba(X_test_credit_scaled)[:, 1]

# Métriques
print("Meilleurs paramètres:", grid_search.best_params_)
print("\nPerformance du modèle de crédit:")
print(f"AUC-ROC: {roc_auc_score(y_test_credit, y_prob_credit):.3f}")
print(f"Précision: {precision_score(y_test_credit, y_pred_credit):.3f}")
print(f"Rappel: {recall_score(y_test_credit, y_pred_credit):.3f}")
print(f"F1-score: {f1_score(y_test_credit, y_pred_credit):.3f}")

# Coefficients et odds ratios
coef_credit = pd.DataFrame({
    'Variable': X_credit.columns,
    'Coefficient': best_model_credit.coef_[0],
    'Odds Ratio': np.exp(best_model_credit.coef_[0])
}).sort_values('Coefficient')

print("\nInterprétation du modèle:")
print(coef_credit)

# Visualisation des coefficients
plt.figure(figsize=(10, 6))
plt.barh(coef_credit['Variable'], coef_credit['Coefficient'])
plt.axvline(x=0, color='r', linestyle='--')
plt.xlabel('Coefficient (impact sur le log-odds de défaut)', fontsize=12)
plt.title('Importance et direction des variables dans le modèle de crédit', fontsize=16)
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

# Exemple d'utilisation pour un nouveau client
new_client = np.array([[32, 45000, 0.4, 3]])  # Âge, revenu, ratio dette, historique
new_client_scaled = scaler.transform(new_client)
default_prob = best_model_credit.predict_proba(new_client_scaled)[0, 1]

print(f"\nProbabilité de défaut pour un nouveau client: {default_prob:.4f}")

# Définition de profils de risque
risk_levels = {
    'Très faible': 0.05,
    'Faible': 0.15,
    'Modéré': 0.3,
    'Élevé': 0.5,
    'Très élevé': 1.0
}

for level, threshold in risk_levels.items():
    if default_prob <= threshold:
        risk_category = level
        break

print(f"Catégorie de risque: {risk_category}")

# Calcul du score de crédit (exemple simplifié, échelle 300-850)
credit_score = 850 - int(default_prob * 550)
print(f"Score de crédit estimé: {credit_score}")

# Simulation de décision
if credit_score >= 700:
    decision = "Crédit approuvé"
    interest_rate = 3.5
elif credit_score >= 650:
    decision = "Crédit approuvé"
    interest_rate = 5.0
elif credit_score >= 600:
    decision = "Crédit approuvé sous conditions"
    interest_rate = 8.5
else:
    decision = "Crédit refusé"
    interest_rate = None

print(f"Décision: {decision}")
if interest_rate:
    print(f"Taux d'intérêt proposé: {interest_rate}%")

Diagnostic médical

# Création d'un jeu de données synthétique pour le dépistage d'une maladie
np.random.seed(42)
n_samples = 1000

# Variables explicatives (biomarqueurs)
age = np.random.normal(50, 15, n_samples)  # Âge moyen de 50 ans
marker_1 = np.random.normal(35, 10, n_samples)  # Biomarqueur 1
marker_2 = np.random.normal(120, 25, n_samples)  # Biomarqueur 2
family_history = np.random.binomial(1, 0.3, n_samples)  # Antécédents familiaux

# Création d'une relation entre les variables et la probabilité de maladie
z = -5 + 0.03 * (age - 40) + 0.05 * (marker_1 - 30) + 0.01 * (marker_2 - 100) + 1.5 * family_history
# Ajout de non-linéarité pour marker_1
z += 0.01 * ((marker_1 - 30) ** 2)
prob_disease = 1 / (1 + np.exp(-z))
disease = (np.random.random(n_samples) < prob_disease).astype(int)

# Création du DataFrame
medical_data = pd.DataFrame({
    'age': age,
    'marker_1': marker_1,
    'marker_2': marker_2,
    'family_history': family_history,
    'disease': disease
})

# Exploration des données
print("Distribution de la variable cible:")
print(medical_data['disease'].value_counts(normalize=True))

# Visualisation des relations
fig, axes = plt.subplots(2, 2, figsize=(15, 10))

# Âge vs Maladie
axes[0, 0].scatter(medical_data['age'], medical_data['disease'], 
                 alpha=0.3, c=medical_data['disease'], cmap='coolwarm')
axes[0, 0].set_xlabel('Âge', fontsize=12)
axes[0, 0].set_ylabel('Maladie (0=non, 1=oui)', fontsize=12)
axes[0, 0].set_title('Relation entre âge et présence de maladie', fontsize=14)
axes[0, 0].grid(True, alpha=0.3)

# Biomarqueur 1 vs Maladie
axes[0, 1].scatter(medical_data['marker_1'], medical_data['disease'], 
                 alpha=0.3, c=medical_data['disease'], cmap='coolwarm')
axes[0, 1].set_xlabel('Biomarqueur 1 (mg/dL)', fontsize=12)
axes[0, 1].set_ylabel('Maladie (0=non, 1=oui)', fontsize=12)
axes[0, 1].set_title('Relation entre biomarqueur 1 et maladie', fontsize=14)
axes[0, 1].grid(True, alpha=0.3)

# Biomarqueur 2 vs Maladie
axes[1, 0].scatter(medical_data['marker_2'], medical_data['disease'], 
                 alpha=0.3, c=medical_data['disease'], cmap='coolwarm')
axes[1, 0].set_xlabel('Biomarqueur 2 (unités)', fontsize=12)
axes[1, 0].set_ylabel('Maladie (0=non, 1=oui)', fontsize=12)
axes[1, 0].set_title('Relation entre biomarqueur 2 et maladie', fontsize=14)
axes[1, 0].grid(True, alpha=0.3)

# Antécédents familiaux vs Maladie
axes[1, 1].violinplot([medical_data[medical_data['family_history']==0]['disease'], 
                     medical_data[medical_data['family_history']==1]['disease']], 
                    positions=[0, 1], showmeans=True)
axes[1, 1].set_xticks([0, 1])
axes[1, 1].set_xticklabels(['Sans antécédents', 'Avec antécédents'])
axes[1, 1].set_ylabel('Probabilité de maladie', fontsize=12)
axes[1, 1].set_title('Impact des antécédents familiaux', fontsize=14)
axes[1, 1].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

# Préparation des données
X_medical = medical_data.drop('disease', axis=1)
y_medical = medical_data['disease']

# Division en ensembles d'entraînement et de test
X_train_med, X_test_med, y_train_med, y_test_med = train_test_split(
    X_medical, y_medical, test_size=0.3, random_state=42, stratify=y_medical
)

# Prétraitement
scaler = StandardScaler()
X_train_med_scaled = scaler.fit_transform(X_train_med)
X_test_med_scaled = scaler.transform(X_test_med)

# Comparaison: modèle linéaire vs modèle avec features non linéaires
from sklearn.preprocessing import PolynomialFeatures

# Création de caractéristiques polynomiales
poly = PolynomialFeatures(degree=2, include_bias=False)
X_train_med_poly = poly.fit_transform(X_train_med_scaled)
X_test_med_poly = poly.transform(X_test_med_scaled)

# Modèle linéaire standard
model_linear = LogisticRegression(random_state=42, class_weight='balanced')
model_linear.fit(X_train_med_scaled, y_train_med)

# Modèle avec caractéristiques polynomiales
model_poly = LogisticRegression(random_state=42, class_weight='balanced')
model_poly.fit(X_train_med_poly, y_train_med)

# Évaluation
y_pred_linear = model_linear.predict(X_test_med_scaled)
y_prob_linear = model_linear.predict_proba(X_test_med_scaled)[:, 1]

y_pred_poly = model_poly.predict(X_test_med_poly)
y_prob_poly = model_poly.predict_proba(X_test_med_poly)[:, 1]

# Comparaison des performances
print("Modèle linéaire:")
print(f"AUC-ROC: {roc_auc_score(y_test_med, y_prob_linear):.3f}")
print(f"Précision: {precision_score(y_test_med, y_pred_linear):.3f}")
print(f"Rappel: {recall_score(y_test_med, y_pred_linear):.3f}")
print(f"F1-score: {f1_score(y_test_med, y_pred_linear):.3f}")

print("\nModèle avec caractéristiques polynomiales:")
print(f"AUC-ROC: {roc_auc_score(y_test_med, y_prob_poly):.3f}")
print(f"Précision: {precision_score(y_test_med, y_pred_poly):.3f}")
print(f"Rappel: {recall_score(y_test_med, y_pred_poly):.3f}")
print(f"F1-score: {f1_score(y_test_med, y_pred_poly):.3f}")

# Optimisation du seuil de décision pour le modèle polynomial
fpr, tpr, thresholds = roc_curve(y_test_med, y_prob_poly)

# Calcul de l'indice de Youden (sensibilité + spécificité - 1)
youden_index = tpr - fpr
optimal_idx = np.argmax(youden_index)
optimal_threshold = thresholds[optimal_idx]

print(f"\nSeuil optimal selon l'indice de Youden: {optimal_threshold:.3f}")
print(f"Sensibilité au seuil optimal: {tpr[optimal_idx]:.3f}")
print(f"Spécificité au seuil optimal: {1-fpr[optimal_idx]:.3f}")

# Application du seuil optimal
y_pred_optimal = (y_prob_poly >= optimal_threshold).astype(int)
print(f"F1-score avec seuil optimal: {f1_score(y_test_med, y_pred_optimal):.3f}")

# Visualisation de la courbe ROC
plt.figure(figsize=(10, 6))
plt.plot(fpr, tpr, 'b-', linewidth=2, label=f'AUC = {roc_auc_score(y_test_med, y_prob_poly):.3f}')
plt.plot([0, 1], [0, 1], 'k--', label='Aléatoire (AUC = 0.5)')
plt.plot(fpr[optimal_idx], tpr[optimal_idx], 'ro', markersize=10, 
        label=f'Seuil optimal = {optimal_threshold:.3f}')
plt.xlabel('Taux de faux positifs (1 - Spécificité)', fontsize=12)
plt.ylabel('Taux de vrais positifs (Sensibilité)', fontsize=12)
plt.title('Courbe ROC pour le diagnostic médical', fontsize=16)
plt.legend(loc="lower right", fontsize=12)
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

# Interprétation clinique du modèle
feature_names = X_medical.columns
coef_linear = model_linear.coef_[0]

print("\nInterprétation du modèle linéaire:")
for feature, coef in zip(feature_names, coef_linear):
    odds_ratio = np.exp(coef)
    if coef > 0:
        effect = "augmente"
    else:
        effect = "diminue"
    
    print(f"• {feature}: Coefficient = {coef:.4f}, Odds Ratio = {odds_ratio:.3f}")
    print(f"  Une augmentation d'une unité standardisée {effect} les chances de maladie de {abs(odds_ratio-1)*100:.1f}%")

# Exemple d'application pour un nouveau patient
new_patient = np.array([[65, 45, 135, 1]])  # Âge, biomarqueur 1, biomarqueur 2, antécédents
new_patient_scaled = scaler.transform(new_patient)
new_patient_poly = poly.transform(new_patient_scaled)

# Prédiction de risque
risk_prob = model_poly.predict_proba(new_patient_poly)[0, 1]
print(f"\nRisque de maladie pour le nouveau patient: {risk_prob:.4f} ({risk_prob*100:.1f}%)")

# Décision clinique
if risk_prob >= optimal_threshold:
    print("Recommandation: Examens supplémentaires recommandés")
    if risk_prob > 0.7:
        print("Priorité: Élevée - Consultation urgente conseillée")
    else:
        print("Priorité: Normale - Planifier un suivi dans les prochaines semaines")
else:
    print("Recommandation: Risque faible - Surveillance standard")

Conclusion

La régression logistique est un algorithme fondamental en classification, offrant un excellent équilibre entre simplicité, interprétabilité et performances. Malgré l’émergence de modèles plus complexes, elle reste un outil privilégié dans de nombreux domaines, notamment la finance, la médecine et le marketing.

Points clés à retenir:

  1. Fondements solides: comprendre les principes mathématiques de la régression logistique fournit une base solide pour d’autres algorithmes de classification plus avancés.
  2. Préparation des données cruciale: les performances de la régression logistique dépendent fortement de la qualité des données et du prétraitement approprié.
  3. Interprétabilité:la régression logistique offre une interprétation claire des coefficients en termes d’impact sur la probabilité, ce qui est essentiel dans des domaines réglementés.
  4. Évaluation multidimensionnelle: au-delà de l’accuracy, les métriques comme AUC-ROC, précision, rappel et F1-score offrent une vision complète des performances.
  5. Optimisation: l’ajustement des hyperparamètres et du seuil de décision peut améliorer significativement les performances pour un cas d’usage spécifique.
  6. Limites connues: comprendre les limites du modèle, notamment sa capacité limitée à capturer des relations non linéaires, permet de savoir quand passer à des modèles plus complexes.

Dans les prochains modules, nous explorerons d’autres algorithmes de classification comme les machines à vecteurs de support (SVM), les arbres de décision et les forêts aléatoires, qui peuvent capturer des relations plus complexes dans les données. Cependant, la régression logistique restera souvent le premier modèle à essayer pour sa simplicité et son interprétabilité.

Publié le
Catégorisé comme Blog

Par Mathieu Klopp

Mathieu Klopp est Data Scientist et ingénieur en Machine Learning, spécialisé dans le traitement du langage naturel (NLP) et les grands modèles de langage. Fort d'une expérience pratique en Python, clustering et analyse sémantique, il développe des solutions d'IA innovantes en combinant expertise technique et vision business.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Quitter la version mobile