---
title: "Classification de la présence d’une maladie cardiaque"
subtitle: "Analyse exploratoire, modèles linéaires et méthodes d’ensemble"
author: "Mehdi Khamassi"
date: today
format:
html:
toc: true
toc-depth: 3
number-sections: true
code-fold: true
code-summary: "Afficher le code"
code-tools: true
embed-resources: true
df-print: paged
theme: cosmo
pdf:
documentclass: scrreprt
papersize: a4
toc: true
toc-depth: 3
number-sections: true
colorlinks: true
keep-tex: true
geometry:
- top=25mm
- bottom=25mm
- left=25mm
- right=25mm
jupyter: python3
execute:
echo: true
warning: false
error: false
cache: false
---
# Introduction
Les maladies cardiovasculaires constituent un enjeu important de santé
publique. L’objectif de cette étude est de construire un modèle de
classification binaire permettant d’estimer la présence d’une maladie
cardiaque à partir de variables cliniques.
La variable cible est `HeartDisease` :
- `0` : absence de maladie cardiaque ;
- `1` : présence de maladie cardiaque.
## Objectifs de l’étude
Cette étude vise à :
1. analyser la structure et la qualité des données ;
2. étudier les relations entre les variables et la cible ;
3. construire une régression logistique non pénalisée ;
4. évaluer ses performances prédictives ;
5. préparer la comparaison avec Ridge, Lasso et Elastic Net ;
6. interpréter les coefficients et les erreurs du modèle.
# Organisation du rapport
Le rapport suit une progression en six étapes : audit et exploration des
données, préparation des variables, estimation de modèles logistiques,
évaluation de modèles fondés sur les arbres, comparaison globale, puis
conclusion et perspectives.
# Acquisition des données
Le jeu de données est téléchargé depuis Kaggle uniquement lorsqu’il n’est
pas déjà présent dans le dossier local `data`.
```{python}
#| label: download-data
from pathlib import Path
import kagglehub
DATASET_HANDLE = "fedesoriano/heart-failure-prediction"
DATA_DIRECTORY = Path("data")
DATA_FILENAME = "heart.csv"
def download_dataset_if_not_exists(
dataset_handle: str,
output_dir: str | Path,
filename: str,
) -> Path:
"""Télécharge le fichier Kaggle s’il n’existe pas localement."""
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
expected_path = output_dir / filename
if expected_path.exists():
print(f"Fichier local utilisé : {expected_path}")
return expected_path
downloaded_path = Path(
kagglehub.dataset_download(
dataset_handle,
path=filename,
output_dir=str(output_dir),
)
)
if not downloaded_path.exists():
raise FileNotFoundError(
f"Le fichier téléchargé est introuvable : {downloaded_path}"
)
print(f"Fichier téléchargé : {downloaded_path}")
return downloaded_path
data_path = download_dataset_if_not_exists(
dataset_handle=DATASET_HANDLE,
output_dir=DATA_DIRECTORY,
filename=DATA_FILENAME,
)
```
# Environnement et configuration de l’étude
```{python}
#| label: configuration
import sys
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import ( train_test_split, cross_validate
)
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (
accuracy_score,
balanced_accuracy_score,
precision_score,
recall_score,
f1_score,
roc_auc_score,
confusion_matrix,
classification_report,
ConfusionMatrixDisplay,
RocCurveDisplay,
PrecisionRecallDisplay,
average_precision_score,
)
RANDOM_STATE = 42
TARGET = "HeartDisease"
pd.set_option("display.max_columns", None)
print("Python :", sys.version)
print("Exécutable :", sys.executable)
```
# Chargement des données
```{python}
#| label: load-data
data = pd.read_csv(data_path)
display(data.head())
```
# Audit et qualité des données
## Dimensions et types
```{python}
n_rows, n_columns = data.shape
print(f"Nombre d’observations : {n_rows}")
print(f"Nombre de variables : {n_columns}")
data.info()
```
::: {.callout-note title="Interprétation"}
- Nous disposons de 918 observations au total.
- Le jeu de données comprend 6 variables qualitatives et 5 variables quantitatives.
- Nous cherchons à prédire la variable binaire `HeartDisease`.
:::
## Valeurs manquantes
```{python}
missing_summary = (
data.isna()
.sum()
.to_frame("nombre")
.assign(
pourcentage=lambda table:
100 * table["nombre"] / len(data)
)
.sort_values("nombre", ascending=False)
)
display(missing_summary.round(2))
```
::: {.callout-note title="Interprétation"}
- Aucune donnée manquante.
:::
## Doublons
```{python}
n_duplicates = data.duplicated().sum()
print(f"Nombre de doublons : {n_duplicates}")
```
::: {.callout-note title="Interprétation"}
- Aucun doublon n’a été détecté.
:::
## Statistiques descriptives
```{python}
display(data.describe().T)
display(data.describe(include="all").T)
```
## Nombre de valeurs uniques
```{python}
unique_summary = (
data.nunique(dropna=False)
.sort_values()
.to_frame("nombre_valeurs_uniques")
)
display(unique_summary)
```
# Analyse exploratoire des données
## Nature des variables
```{python}
numeric_features = [
"Age",
"RestingBP",
"Cholesterol",
"MaxHR",
"Oldpeak",
]
categorical_features = [
"Sex",
"ChestPainType",
"FastingBS",
"RestingECG",
"ExerciseAngina",
"ST_Slope",
]
print("Variables numériques :", numeric_features)
print("Variables catégorielles :", categorical_features)
```
`FastingBS` est stockée sous forme numérique, mais représente une variable
catégorielle binaire.
## Distribution de la variable cible
```{python}
#| label: fig-target-distribution
#| fig-cap: "Distribution de la variable cible HeartDisease"
target_summary = pd.DataFrame(
{
"effectif": data[TARGET].value_counts().sort_index(),
"proportion": (
data[TARGET]
.value_counts(normalize=True)
.sort_index()
),
}
)
display(target_summary.round(3))
ax = target_summary["effectif"].plot(
kind="bar",
figsize=(7, 4),
)
ax.set_title("Distribution de la variable cible")
ax.set_xlabel("Classe")
ax.set_ylabel("Effectif")
ax.set_xticklabels(["Absence", "Présence"], rotation=0)
plt.tight_layout()
plt.show()
```
La proportion de la classe $k$ est calculée par :
$$
\widehat{p}_k
=
\frac{n_k}{n},
$$
où $n_k$ désigne le nombre d’observations de la classe $k$ et $n$
le nombre total d’observations.
::: {.callout-note title="Interprétation"}
La classe majoritaire est `HeartDisease = 1`, avec environ 55,3 % des
observations, contre 44,7 % pour la classe `HeartDisease = 0`.
La répartition peut être considérée comme relativement équilibrée, car les
deux classes sont bien représentées et le rapport entre leurs effectifs reste
proche de 1. Le déséquilibre est donc modéré et ne nécessite pas, à ce stade,
de méthode de rééchantillonnage comme SMOTE ou le sous-échantillonnage.
Une séparation stratifiée reste néanmoins utilisée afin de conserver ces
proportions dans les jeux d’apprentissage et de test.
:::
## Distribution des variables catégorielles
```{python}
for column in categorical_features:
counts = (
data[column]
.value_counts(dropna=False)
.sort_values(ascending=False)
)
ax = counts.plot(
kind="bar",
figsize=(7, 4),
)
ax.set_title(f"Distribution de {column}")
ax.set_xlabel(column)
ax.set_ylabel("Nombre d’observations")
plt.xticks(rotation=45, ha="right")
plt.tight_layout()
plt.show()
```
## Variables catégorielles selon la cible
```{python}
for column in categorical_features:
cross_table = pd.crosstab(
data[column],
data[TARGET],
normalize="index",
)
ax = cross_table.plot(
kind="bar",
stacked=True,
figsize=(7, 4),
)
ax.set_title(f"Proportion de HeartDisease selon {column}")
ax.set_xlabel(column)
ax.set_ylabel("Proportion")
ax.legend(["Absence", "Présence"], title="HeartDisease")
plt.xticks(rotation=45, ha="right")
plt.tight_layout()
plt.show()
```
::: {.callout-note title="Interprétation"}
### Interprétation de `ChestPainType`
* La modalité **`ASY`** semble être la plus fortement associée à la présence d’une maladie cardiaque : la proportion d’individus appartenant à la classe `HeartDisease = 1` y est nettement plus élevée.
* À l’inverse, la modalité **`ATA`** semble être la moins associée à la présence d’une maladie cardiaque, avec une proportion plus importante d’observations appartenant à la classe `HeartDisease = 0`.
* Les modalités **`NAP`** et **`TA`** présentent une répartition plus intermédiaire entre les deux classes. Leur association avec la cible paraît donc moins marquée que pour `ASY` et `ATA`.
* Ces observations décrivent une **association dans l’échantillon**. Elles ne permettent pas, à elles seules, de conclure à une relation causale. Il faudra confirmer leur importance dans le modèle de classification et, si besoin, par un test d’association comme le khi-deux.
### Interprétation de `ExerciseAngina`
La présence d’une angine provoquée par l’exercice semble fortement associée
à la présence d’une maladie cardiaque. Environ 90 % des individus pour
lesquels `ExerciseAngina = Y` appartiennent à la classe `HeartDisease = 1`,
contre approximativement 30 % lorsque `ExerciseAngina = N`.
Cette différence suggère que `ExerciseAngina` possède un pouvoir discriminant
important pour la classification. Il s’agit toutefois d’une association
observée dans l’échantillon et non d’une preuve de causalité.
### Interprétation de `ST_Slope`
La variable `ST_Slope` présente une association particulièrement marquée
avec la cible. Environ 90 % des individus appartenant aux modalités `Flat`
et `Down` présentent une maladie cardiaque, contre seulement environ 10 %
pour la modalité `Up`.
Ces résultats suggèrent que `ST_Slope` pourrait avoir un pouvoir discriminant
important dans le modèle de classification. La modalité `Up` est davantage
associée à l’absence de maladie cardiaque, tandis que `Flat` et `Down` sont
fortement associées à sa présence.
Cette interprétation doit toutefois tenir compte des effectifs propres à
chaque modalité, notamment pour `Down`, et ne constitue pas une preuve de
causalité.
### Interprétation de `Sex`
Dans le jeu de données étudié, la proportion d’individus présentant une
maladie cardiaque est plus élevée chez les hommes (`Sex = M`) que chez les
femmes (`Sex = F`). La modalité masculine semble donc davantage associée à
la classe `HeartDisease = 1`.
La variable `Sex` pourrait ainsi fournir une information utile au modèle de
classification. Cette conclusion doit néanmoins être nuancée, car les hommes
sont nettement plus représentés que les femmes dans l’échantillon. Les
résultats décrivent une association statistique dans les données et ne
permettent pas d’établir une relation causale.
:::
## Distribution des variables numériques
```{python}
for column in numeric_features:
ax = data[column].plot(
kind="hist",
bins=25,
edgecolor="black",
figsize=(7, 4),
)
ax.set_title(f"Distribution de {column}")
ax.set_xlabel(column)
ax.set_ylabel("Fréquence")
plt.tight_layout()
plt.show()
```
## Détection graphique des valeurs atypiques
```{python}
for column in numeric_features:
data.boxplot(
column=column,
vert=False,
figsize=(7, 3),
)
plt.title(f"Boîte à moustaches de {column}")
plt.xlabel(column)
plt.tight_layout()
plt.show()
```
::: {.callout-warning title="Valeurs atypiques"}
Une valeur atypique ne doit pas être supprimée automatiquement. Elle peut être
rare mais valide.
:::
## Variables numériques selon la cible
```{python}
for column in numeric_features:
data.boxplot(
column=column,
by=TARGET,
figsize=(7, 4),
)
plt.title(f"{column} selon HeartDisease")
plt.suptitle("")
plt.xlabel("HeartDisease")
plt.ylabel(column)
plt.tight_layout()
plt.show()
```
## Pairplot des variables numériques
```{python}
pairplot_columns = numeric_features + [TARGET]
sns.pairplot(
data[pairplot_columns],
hue=TARGET,
corner=True,
diag_kind="hist",
)
plt.show()
```
## Matrice de corrélation des variables numériques
```{python}
correlation_matrix = (
data[numeric_features]
.corr(method="pearson")
)
display(correlation_matrix.round(2))
plt.figure(figsize=(9, 7))
sns.heatmap(
correlation_matrix,
annot=True,
cmap="coolwarm",
fmt=".2f",
linewidths=0.5,
center=0,
)
plt.title("Matrice de corrélation de Pearson")
plt.tight_layout()
plt.show()
```
Le coefficient de corrélation de Pearson entre deux variables $X$ et
$Y$ est défini par :
$$
r_{XY}
=
\frac{
\sum_{i=1}^{n}(x_i-\bar{x})(y_i-\bar{y})
}{
\sqrt{\sum_{i=1}^{n}(x_i-\bar{x})^2}
\sqrt{\sum_{i=1}^{n}(y_i-\bar{y})^2}
}.
$$
::: {.callout-note title="Interprétation"}
Une corrélation mesure une association linéaire. Elle ne démontre pas une
relation causale.
:::
## Corrélation des variables numériques avec la cible
```{python}
correlation_columns = numeric_features + [TARGET]
target_correlation_matrix = data[
correlation_columns
].corr(method="pearson")
target_correlations = (
target_correlation_matrix[TARGET]
.drop(TARGET)
.sort_values()
)
display(
target_correlations
.round(3)
.to_frame("corrélation")
)
ax = target_correlations.plot(
kind="barh",
figsize=(8, 5),
)
ax.axvline(0, linewidth=1)
ax.set_title("Corrélations avec HeartDisease")
ax.set_xlabel("Coefficient de corrélation de Pearson")
ax.set_ylabel("Variable")
plt.tight_layout()
plt.show()
```
# Préparation des données
## Variables explicatives et variable cible
```{python}
X = data.drop(columns=[TARGET])
y = data[TARGET].astype(int)
print("Dimensions de X :", X.shape)
print("Dimensions de y :", y.shape)
```
## Séparation apprentissage-test
```{python}
X_train, X_test, y_train, y_test = train_test_split(
X,
y,
test_size=0.20,
random_state=RANDOM_STATE,
stratify=y,
)
```
Le jeu de données est divisé en deux sous-ensembles disjoints :
$$
\mathcal{D}
=
\mathcal{D}_{\mathrm{train}}
\cup
\mathcal{D}_{\mathrm{test}},
$$
avec :
$$
\mathcal{D}_{\mathrm{train}}
\cap
\mathcal{D}_{\mathrm{test}}
=
\varnothing.
$$
## Vérification de la stratification
```{python}
class_distribution = pd.DataFrame(
{
"jeu_complet":
y.value_counts(normalize=True).sort_index(),
"apprentissage":
y_train.value_counts(normalize=True).sort_index(),
"test":
y_test.value_counts(normalize=True).sort_index(),
}
)
display(class_distribution.round(3))
```
::: {.callout-note title="Interprétation"}
Les proportions sont très proches dans
les trois colonnes.
:::
## Encodage des variables catégorielles
```{python}
encoder = OneHotEncoder(
sparse_output=False,
handle_unknown="ignore",
drop="first",
)
X_train_categorical_encoded = encoder.fit_transform(
X_train[categorical_features]
)
X_test_categorical_encoded = encoder.transform(
X_test[categorical_features]
)
encoded_feature_names = encoder.get_feature_names_out(
categorical_features
)
X_train_categorical_encoded = pd.DataFrame(
X_train_categorical_encoded,
columns=encoded_feature_names,
index=X_train.index,
)
X_test_categorical_encoded = pd.DataFrame(
X_test_categorical_encoded,
columns=encoded_feature_names,
index=X_test.index,
)
```
Pour une variable catégorielle $C$ possédant les modalités
$c_1,\ldots,c_K$, l’indicatrice associée à la modalité $c_k$ est :
$$
I_{ik}
=
\begin{cases}
1 & \text{si l’observation } i \text{ appartient à } c_k,\\
0 & \text{sinon.}
\end{cases}
$$
Une modalité est supprimée et sert de référence.
## Standardisation des variables numériques
```{python}
scaler = StandardScaler()
X_train_numeric_scaled = scaler.fit_transform(
X_train[numeric_features]
)
X_test_numeric_scaled = scaler.transform(
X_test[numeric_features]
)
X_train_numeric_scaled = pd.DataFrame(
X_train_numeric_scaled,
columns=numeric_features,
index=X_train.index,
)
X_test_numeric_scaled = pd.DataFrame(
X_test_numeric_scaled,
columns=numeric_features,
index=X_test.index,
)
```
Chaque variable numérique est standardisée selon :
$$
z_{ij}
=
\frac{x_{ij}-\widehat{\mu}_j}
{\widehat{\sigma}_j},
$$
où $\widehat{\mu}_j$ et $\widehat{\sigma}_j$ sont estimés uniquement
sur le jeu d’apprentissage.
## Assemblage des données préparées
```{python}
X_train_ready = pd.concat(
[
X_train_numeric_scaled,
X_train_categorical_encoded,
],
axis=1,
)
X_test_ready = pd.concat(
[
X_test_numeric_scaled,
X_test_categorical_encoded,
],
axis=1,
)
print("Train :", X_train_ready.shape)
print("Test :", X_test_ready.shape)
display(X_train_ready.head())
```
# Régression logistique non pénalisée
## Formulation mathématique
Pour une observation $x_i$, la probabilité de la classe positive est :
$$
p_i
=
P(Y_i=1\mid X_i=x_i)
=
\frac{1}{
1+\exp\left(
-\left(\beta_0+x_i^\top\beta\right)
\right)
}.
$$
Le logarithme des odds est linéaire :
$$
\log\left(
\frac{p_i}{1-p_i}
\right)
=
\beta_0+x_i^\top\beta.
$$
## Estimation du modèle
```{python}
model_logistic = LogisticRegression(
penalty=None,
max_iter=5000,
)
model_logistic.fit(
X_train_ready,
y_train,
)
```
## Coefficients estimés
```{python}
coefficient_table = pd.DataFrame(
{
"variable": X_train_ready.columns,
"coefficient": model_logistic.coef_[0],
}
)
coefficient_table["odds_ratio"] = np.exp(
coefficient_table["coefficient"]
)
coefficient_table["coefficient_absolu"] = (
coefficient_table["coefficient"].abs()
)
coefficient_table = coefficient_table.sort_values(
"coefficient_absolu",
ascending=False,
)
print(
f"Intercept : {model_logistic.intercept_[0]:.4f}"
)
display(
coefficient_table[
[
"variable",
"coefficient",
"odds_ratio",
]
].round(4)
)
```
L’odds ratio associé à la variable $X_j$ est :
$$
OR_j=e^{\beta_j}.
$$
- $OR_j>1$ : augmentation des odds de la classe positive ;
- $OR_j<1$ : diminution des odds ;
- $OR_j=1$ : absence d’effet linéaire estimé.
::: {.callout-note title="Interprétation des coefficients"}
Les coefficients de la régression logistique s’interprètent sur l’échelle du
logarithme des odds. L’odds ratio associé à une variable est obtenu par :
$$
OR_j = e^{\beta_j}.
$$
Un coefficient positif est associé à une augmentation des odds de
`HeartDisease = 1`, tandis qu’un coefficient négatif est associé à une
diminution des odds, toutes les autres variables étant maintenues constantes.
### Intercept
L’intercept est égal à `0.0699`, soit un odds ratio d’environ :
$$
e^{0.0699} \approx 1.07.
$$
Il représente les odds de base lorsque toutes les variables numériques
standardisées valent zéro et que toutes les variables catégorielles sont dans
leur modalité de référence. Son interprétation clinique directe reste limitée,
car ce profil de référence est principalement une construction mathématique.
### Variables catégorielles associées à une augmentation des odds
La modalité `Sex_M` possède un coefficient de `1.2401`, correspondant à un
odds ratio de `3.4559`. À caractéristiques égales, les odds prédites de maladie
cardiaque sont donc environ 3,46 fois plus élevées pour les hommes que pour les
femmes, qui constituent ici la modalité de référence.
La modalité `FastingBS_1` présente un odds ratio de `2.8714`. Les individus
ayant `FastingBS = 1` ont donc des odds prédites de maladie cardiaque environ
2,87 fois plus élevées que ceux ayant `FastingBS = 0`, toutes choses égales
par ailleurs.
La modalité `ST_Slope_Flat` est associée à un odds ratio de `2.6683`. Par
rapport à la modalité de référence de `ST_Slope`, les odds prédites de maladie
cardiaque sont multipliées par environ 2,67.
La modalité `ExerciseAngina_Y` possède un odds ratio de `2.4686`. La présence
d’une angine provoquée par l’exercice est donc associée à des odds prédites
environ 2,47 fois plus élevées que l’absence d’angine à l’effort.
### Variables catégorielles associées à une diminution des odds
Les modalités `ChestPainType_NAP`, `ChestPainType_ATA` et
`ChestPainType_TA` possèdent des coefficients fortement négatifs, avec des
odds ratios respectifs de `0.1439`, `0.1815` et `0.2205`.
Par rapport à la modalité de référence de `ChestPainType`, qui est
probablement `ASY`, ces modalités sont associées à des odds nettement plus
faibles de maladie cardiaque :
- `NAP` : réduction d’environ 85,6 % des odds ;
- `ATA` : réduction d’environ 81,9 % des odds ;
- `TA` : réduction d’environ 77,9 % des odds.
La modalité `ST_Slope_Up` présente un odds ratio de `0.2128`. Par rapport à
la modalité de référence de `ST_Slope`, les odds prédites sont réduites
d’environ 78,7 %.
Les modalités `RestingECG_ST` et `RestingECG_Normal` sont également associées
à une diminution des odds par rapport à la modalité de référence, avec des
odds ratios respectifs de `0.6538` et `0.6649`.
### Variables numériques
Les variables numériques ont été standardisées. Leur interprétation
correspond donc à une augmentation d’un écart-type de la variable, et non
d’une unité dans son échelle originale.
`Oldpeak` possède un coefficient positif de `0.2681`, soit un odds ratio de
`1.3075`. Une augmentation d’un écart-type de `Oldpeak` est associée à une
augmentation d’environ 30,8 % des odds prédites de maladie cardiaque.
`Cholesterol` présente un coefficient négatif de `-0.5095`, soit un odds
ratio de `0.6008`. Une augmentation d’un écart-type du cholestérol est
associée à une diminution d’environ 39,9 % des odds prédites. Ce résultat peut
sembler contre-intuitif et doit être interprété avec prudence, notamment en
raison de la présence possible de valeurs nulles codant des données
manquantes.
`MaxHR` possède un odds ratio de `0.8412`, ce qui correspond à une diminution
d’environ 15,9 % des odds pour une augmentation d’un écart-type.
`RestingBP` et `Age` ont des coefficients proches de zéro, avec des odds ratios
respectifs de `1.0473` et `1.0360`. Leur contribution linéaire au modèle
semble donc relativement faible une fois les autres variables prises en
compte.
### Variables les plus influentes
En valeur absolue, les coefficients les plus importants sont associés à :
- `ChestPainType_NAP` ;
- `ChestPainType_ATA` ;
- `ST_Slope_Up` ;
- `ChestPainType_TA` ;
- `Sex_M` ;
- `FastingBS_1`.
Ces variables semblent avoir le plus fort effet sur le logit prédit du modèle.
### Modalités de référence
Avec `OneHotEncoder(drop="first")`, une modalité de chaque variable
catégorielle est omise et sert de référence. D’après les colonnes présentes,
les références semblent être :
- `Sex` : `F` ;
- `ChestPainType` : `ASY` ;
- `FastingBS` : `0` ;
- `RestingECG` : probablement `LVH` ;
- `ExerciseAngina` : `N` ;
- `ST_Slope` : probablement `Down`.
Les coefficients des modalités encodées doivent toujours être interprétés par
rapport à ces catégories de référence.
### Prudence d’interprétation
Ces coefficients décrivent des associations conditionnelles dans le jeu de
données. Ils ne prouvent pas une relation causale. Leur stabilité devra être
évaluée avec une validation croisée et comparée aux modèles régularisés Ridge,
Lasso et Elastic Net.
:::
## Évaluation prédictive
### Prédictions sur le jeu de test
```{python}
y_pred = model_logistic.predict(
X_test_ready
)
y_proba = model_logistic.predict_proba(
X_test_ready
)[:, 1]
```
Avec un seuil de $0{,}5$ :
$$
\widehat{Y}_i
=
\begin{cases}
1 & \text{si } \widehat{p}_i \geq 0{,}5,\\
0 & \text{sinon.}
\end{cases}
$$
### Tableau des métriques
```{python}
tn, fp, fn, tp = confusion_matrix(
y_test,
y_pred,
).ravel()
sensitivity = tp / (tp + fn)
specificity = tn / (tn + fp)
logistic_metrics = pd.Series(
{
"Accuracy":
accuracy_score(y_test, y_pred),
"Balanced accuracy":
balanced_accuracy_score(y_test, y_pred),
"Précision":
precision_score(y_test, y_pred),
"Sensibilité / Recall":
sensitivity,
"Spécificité":
specificity,
"F1-score":
f1_score(y_test, y_pred),
"ROC-AUC":
roc_auc_score(y_test, y_proba),
"Average Precision":
average_precision_score(y_test, y_proba),
},
name="Régression logistique",
)
display(
logistic_metrics
.to_frame("score")
.round(3)
)
```
À partir des vrais positifs $TP$, vrais négatifs $TN$, faux positifs
$FP$ et faux négatifs $FN$ :
$$
\operatorname{Accuracy}
=
\frac{TP+TN}
{TP+TN+FP+FN}.
$$
$$
\operatorname{Precision}
=
\frac{TP}
{TP+FP}.
$$
$$
\operatorname{Recall}
=
\frac{TP}
{TP+FN}.
$$
$$
\operatorname{Specificity}
=
\frac{TN}
{TN+FP}.
$$
$$
F_1
=
2
\frac{
\operatorname{Precision}\times\operatorname{Recall}
}{
\operatorname{Precision}+\operatorname{Recall}
}.
$$
$$
\operatorname{BalancedAccuracy}
=
\frac{
\operatorname{Recall}+\operatorname{Specificity}
}{2}.
$$
::: {.callout-important title="Interprétation performance du premier modèle"}
La régression logistique non pénalisée obtient de bonnes performances sur le
jeu de test. Elle classe correctement 88,6 % des observations et présente
une balanced accuracy de 88,0 %, ce qui indique des performances relativement
homogènes entre les deux classes.
Le modèle détecte 93,1 % des individus appartenant réellement à la classe
`HeartDisease = 1`. D’après la matrice de confusion, il identifie environ
95 des 102 cas positifs et produit seulement 7 faux négatifs. Cette forte
sensibilité est particulièrement intéressante dans une logique de dépistage.
La précision de 87,2 % signifie que la majorité des individus classés comme
positifs le sont effectivement. Le modèle produit néanmoins davantage de faux
positifs que de faux négatifs, ce qui traduit un choix implicite favorable à
la détection des cas positifs.
La spécificité atteint 82,9 % : le modèle reconnaît correctement la majorité
des observations négatives, mais ses performances sont légèrement meilleures
pour la détection de la présence de maladie que pour la confirmation de son
absence.
Le score F1 de 0,900 confirme un très bon compromis entre précision et rappel.
La ROC-AUC de 0,929 et l’Average Precision de 0,936 montrent également une
forte capacité de discrimination sur l’ensemble des seuils de décision.
Ces résultats sont prometteurs, mais ils proviennent d’une seule séparation
apprentissage-test. Ils devront être confirmés par validation croisée, puis
comparés aux régressions logistiques Ridge, Lasso et Elastic Net. Le jeu de
test ne devra pas être utilisé à répétition pour choisir les hyperparamètres.
:::
### Rapport de classification
```{python}
print(
classification_report(
y_test,
y_pred,
target_names=[
"Absence de maladie",
"Présence de maladie",
],
digits=3,
)
)
```
### Matrice de confusion
```{python}
#| label: fig-confusion-logistic
#| fig-cap: "Matrice de confusion de la régression logistique non pénalisée"
ConfusionMatrixDisplay.from_predictions(
y_test,
y_pred,
display_labels=[
"Absence",
"Présence",
],
)
plt.title("Matrice de confusion — Régression logistique")
plt.tight_layout()
plt.show()
```
La matrice de confusion est :
$$
\begin{pmatrix}
TN & FP\\
FN & TP
\end{pmatrix}.
$$
::: {.callout-important title="Interprétation"}
- vrais positifs : `{python} tp` ;
- vrais négatifs : `{python} tn` ;
- faux positifs : `{python} fp` ;
- faux négatifs : `{python} fn`.
Le modèle identifie correctement 95 individus présentant une maladie
cardiaque et 68 individus n’en présentant pas. Il classe toutefois
14 individus sains comme malades, ce qui correspond aux faux positifs,
et ne détecte pas 7 individus réellement malades, ce qui correspond aux
faux négatifs.
Le nombre relativement faible de faux négatifs explique la sensibilité
élevée du modèle, égale à environ 93,1 %.
Les faux négatifs sont particulièrement importants dans un contexte de
dépistage.
:::
### Courbe ROC
```{python}
#| label: fig-roc-logistic
#| fig-cap: "Courbe ROC de la régression logistique non pénalisée"
roc_auc = roc_auc_score(
y_test,
y_proba,
)
RocCurveDisplay.from_predictions(
y_test,
y_proba,
name=f"Régression logistique — AUC = {roc_auc:.3f}",
)
plt.plot(
[0, 1],
[0, 1],
linestyle="--",
label="Modèle aléatoire",
)
plt.title("Courbe ROC")
plt.xlabel("Taux de faux positifs")
plt.ylabel("Taux de vrais positifs")
plt.legend()
plt.tight_layout()
plt.show()
```
Pour un seuil $t$ :
$$
TPR(t)
=
\frac{TP(t)}
{TP(t)+FN(t)},
$$
et :
$$
FPR(t)
=
\frac{FP(t)}
{FP(t)+TN(t)}.
$$
L’aire sous la courbe ROC est :
$$
AUC
=
\int_0^1 TPR(FPR)\,d(FPR).
$$
::: {.callout-note title="Interprétation de l’AUC"}
Une AUC proche de $0{,}5$ correspond à une
capacité de discrimination proche du hasard. Une valeur proche de $1$
indique une meilleure discrimination.
:::
### Courbe précision-rappel
```{python}
#| label: fig-pr-logistic
#| fig-cap: "Courbe précision-rappel de la régression logistique"
average_precision = average_precision_score(
y_test,
y_proba,
)
PrecisionRecallDisplay.from_predictions(
y_test,
y_proba,
name=f"AP = {average_precision:.3f}",
)
plt.title("Courbe précision-rappel")
plt.xlabel("Rappel")
plt.ylabel("Précision")
plt.legend()
plt.tight_layout()
plt.show()
```
## Limites du premier modèle
La régression logistique non pénalisée constitue une référence
interprétable, mais elle peut présenter plusieurs limites :
- sensibilité à la multicolinéarité ;
- coefficients potentiellement instables ;
- risque de surajustement ;
- relation supposée linéaire entre les variables et le logit ;
- seuil fixé arbitrairement à $0{,}5$ ;
- absence de validation croisée à cette étape ;
- absence de validation externe sur une autre population.
Les modèles Ridge, Lasso et Elastic Net seront étudiés dans les sections
suivantes.
# Régression logistique Ridge
## Principe de la régularisation L2
La régression logistique Ridge ajoute une pénalité quadratique à la fonction
de perte :
$$
\widehat{\beta}^{\,\text{Ridge}}
=
\arg\min_{\beta}
\left[
-\ell(\beta)
+
\lambda
\sum_{j=1}^{p}\beta_j^2
\right].
$$
La pénalité réduit l’amplitude des coefficients sans les annuler
généralement complètement. Elle peut améliorer la stabilité du modèle lorsque
les variables sont corrélées ou lorsque le modèle non pénalisé présente une
variance élevée.
Dans scikit-learn, la force de la régularisation est contrôlée par $C$ :
$$
C \propto \frac{1}{\lambda}.
$$
Ainsi :
- une petite valeur de $C$ correspond à une forte régularisation ;
- une grande valeur de $C$ correspond à une faible régularisation.
## Validation croisée stratifiée
La valeur de $C$ est choisie uniquement à partir du jeu d’apprentissage au
moyen d’une validation croisée stratifiée à cinq plis. Chaque pli conserve
approximativement la proportion des classes. :contentReference[oaicite:1]{index=1}
```{python}
from sklearn.model_selection import (
StratifiedKFold,
GridSearchCV,
)
cv = StratifiedKFold(
n_splits=5,
shuffle=True,
random_state=RANDOM_STATE,
)
```
## Définition du modèle Ridge
```{python}
model_ridge_base = LogisticRegression(
C=1.0,
l1_ratio=0,
solver="lbfgs",
max_iter=5000,
)
```
`l1_ratio=0` correspond à une pénalisation L2 pure. Le solveur `lbfgs` est
compatible avec cette pénalisation. :contentReference[oaicite:2]{index=2}
## Recherche de la meilleure valeur de $C$
```{python}
ridge_parameter_grid = {
"C": np.logspace(-4, 4, 17),
}
ridge_grid_search = GridSearchCV(
estimator=model_ridge_base,
param_grid=ridge_parameter_grid,
scoring={
"roc_auc": "roc_auc",
"accuracy": "accuracy",
"balanced_accuracy": "balanced_accuracy",
"precision": "precision",
"recall": "recall",
"f1": "f1",
},
refit="roc_auc",
cv=cv,
n_jobs=-1,
return_train_score=True,
)
ridge_grid_search.fit(
X_train_ready,
y_train,
)
```
Avec `refit="roc_auc"`, la combinaison obtenant la meilleure ROC-AUC moyenne
est réentraînée sur l’ensemble du jeu d’apprentissage et devient accessible
dans `best_estimator_`. :contentReference[oaicite:3]{index=3}
## Meilleur hyperparamètre
```{python}
best_ridge_c = ridge_grid_search.best_params_["C"]
best_ridge_cv_auc = ridge_grid_search.best_score_
print(f"Meilleure valeur de C : {best_ridge_c:.6g}")
print(
"ROC-AUC moyenne en validation croisée : "
f"{best_ridge_cv_auc:.3f}"
)
model_ridge = ridge_grid_search.best_estimator_
```
::: {.callout-note title="Interprétation"}
À compléter après exécution :
- meilleure valeur de $C$ ;
- intensité correspondante de la régularisation ;
- ROC-AUC moyenne obtenue en validation croisée ;
- comparaison avec les résultats du modèle non pénalisé.
Une petite valeur de $C$ indique que la validation croisée préfère une
régularisation forte. Une valeur très élevée suggère que la pénalisation
apporte peu d’amélioration.
:::
## Résultats complets de la validation croisée
```{python}
ridge_cv_results = pd.DataFrame(
ridge_grid_search.cv_results_
)
ridge_cv_summary = (
ridge_cv_results[
[
"param_C",
"mean_train_roc_auc",
"mean_test_roc_auc",
"std_test_roc_auc",
"mean_test_accuracy",
"mean_test_balanced_accuracy",
"mean_test_precision",
"mean_test_recall",
"mean_test_f1",
"rank_test_roc_auc",
]
]
.sort_values("param_C")
.reset_index(drop=True)
)
display(ridge_cv_summary.round(4))
```
## Courbe de validation de $C$
```{python}
#| label: fig-ridge-validation
#| fig-cap: "Performance du modèle Ridge selon la valeur de C"
ridge_cv_plot = ridge_cv_summary.copy()
ridge_cv_plot["param_C"] = ridge_cv_plot[
"param_C"
].astype(float)
plt.figure(figsize=(8, 5))
plt.semilogx(
ridge_cv_plot["param_C"],
ridge_cv_plot["mean_train_roc_auc"],
marker="o",
label="Apprentissage",
)
plt.semilogx(
ridge_cv_plot["param_C"],
ridge_cv_plot["mean_test_roc_auc"],
marker="o",
label="Validation",
)
plt.axvline(
best_ridge_c,
linestyle="--",
label=f"Meilleur C = {best_ridge_c:.4g}",
)
plt.xlabel("C")
plt.ylabel("ROC-AUC moyenne")
plt.title("Choix de la force de régularisation Ridge")
plt.legend()
plt.tight_layout()
plt.show()
```
::: {.callout-note title="Interprétation de la courbe"}
La ROC-AUC moyenne de validation augmente fortement lorsque $C$ passe de
valeurs très faibles à des valeurs intermédiaires. Pour les petites valeurs de
$C$, la régularisation est très forte et les performances sont nettement
plus faibles, ce qui suggère un sous-ajustement.
La meilleure valeur sélectionnée est :
$$
C \approx 3{,}162.
$$
À cette valeur, la ROC-AUC moyenne de validation atteint environ $0{,}925$.
Au-delà, la performance de validation se stabilise puis diminue très
légèrement. Une valeur de $C$ plus grande, correspondant à une
régularisation plus faible, n’apporte donc pas d’amélioration notable.
Les courbes d’apprentissage et de validation restent proches sur l’ensemble
de la zone optimale. L’écart est d’environ un point de ROC-AUC, ce qui ne
suggère pas de surajustement important. La performance d’apprentissage est
naturellement un peu supérieure à celle de validation, mais l’écart demeure
faible et stable.
Le modèle préfère ainsi une régularisation L2 relativement faible à modérée.
Une régularisation trop forte dégrade clairement les performances, tandis
qu’une régularisation presque nulle n’améliore plus la capacité de
généralisation.
Le choix de $C \approx 3{,}162$ constitue donc un compromis pertinent entre
stabilité des coefficients et performance prédictive.
:::
## Coefficients du modèle Ridge
```{python}
ridge_coefficient_table = pd.DataFrame(
{
"variable": X_train_ready.columns,
"coefficient_ridge": model_ridge.coef_[0],
}
)
ridge_coefficient_table["odds_ratio_ridge"] = np.exp(
ridge_coefficient_table["coefficient_ridge"]
)
ridge_coefficient_table["coefficient_absolu"] = (
ridge_coefficient_table["coefficient_ridge"].abs()
)
ridge_coefficient_table = ridge_coefficient_table.sort_values(
"coefficient_absolu",
ascending=False,
)
print(
f"Intercept Ridge : {model_ridge.intercept_[0]:.4f}"
)
display(
ridge_coefficient_table[
[
"variable",
"coefficient_ridge",
"odds_ratio_ridge",
]
].round(4)
)
```
## Comparaison avec les coefficients non pénalisés
```{python}
coefficient_comparison = (
coefficient_table[
[
"variable",
"coefficient",
]
]
.rename(
columns={
"coefficient": "non_penalise",
}
)
.merge(
ridge_coefficient_table[
[
"variable",
"coefficient_ridge",
]
],
on="variable",
how="inner",
)
)
coefficient_comparison["reduction_absolue"] = (
coefficient_comparison["non_penalise"].abs()
-
coefficient_comparison["coefficient_ridge"].abs()
)
coefficient_comparison["taux_reduction"] = (
1
-
coefficient_comparison["coefficient_ridge"].abs()
/ coefficient_comparison["non_penalise"].abs()
)
display(
coefficient_comparison
.sort_values(
"coefficient_ridge",
key=lambda values: values.abs(),
ascending=False,
)
.round(4)
)
```
```{python}
coefficient_plot = (
coefficient_comparison
.set_index("variable")[
[
"non_penalise",
"coefficient_ridge",
]
]
.sort_values("non_penalise")
)
coefficient_plot.plot(
kind="barh",
figsize=(10, 8),
)
plt.axvline(0, linewidth=1)
plt.xlabel("Coefficient estimé")
plt.ylabel("Variable")
plt.title(
"Comparaison des coefficients : "
"non pénalisé et Ridge"
)
plt.tight_layout()
plt.show()
```
::: {.callout-note title="Interprétation des coefficients Ridge"}
La pénalisation Ridge modifie relativement peu les coefficients du modèle
non pénalisé. Leurs signes restent tous identiques, ce qui montre que la
direction des associations estimées est stable après régularisation.
Les réductions les plus importantes concernent principalement :
- `ChestPainType_TA`, dont le coefficient diminue d’environ 9,4 % ;
- `RestingECG_ST`, avec une diminution d’environ 9,8 % ;
- `RestingBP`, avec une réduction relative d’environ 9,1 %, mais sur un
coefficient initial déjà très faible ;
- `RestingECG_Normal`, avec une diminution d’environ 6,3 % ;
- `ChestPainType_ATA` et `ChestPainType_NAP`, avec des réductions proches de
5 %.
Malgré cette contraction, les variables les plus influentes du modèle
non pénalisé restent également les plus importantes dans le modèle Ridge.
C’est notamment le cas de `ChestPainType_NAP`, `ChestPainType_ATA`,
`ST_Slope_Up`, `ChestPainType_TA`, `Sex_M` et `FastingBS_1`.
Certaines estimations augmentent très légèrement en valeur absolue, par
exemple `ST_Slope_Flat`, `ExerciseAngina_Y`, `Oldpeak` et `MaxHR`. Cela ne
contredit pas le principe de Ridge : la pénalisation agit simultanément sur
l’ensemble des coefficients, et la réestimation conjointe peut conduire à de
petites augmentations individuelles lorsque les variables sont corrélées.
Globalement, Ridge produit donc une version légèrement plus stable et plus
régularisée du premier modèle, sans modifier fortement son interprétation.
Aucun coefficient n’est annulé, ce qui est cohérent avec la pénalisation L2 :
Ridge réduit l’amplitude des coefficients, mais ne réalise pas de sélection
stricte de variables.
Les odds ratios restent ainsi proches de ceux du modèle non pénalisé. Les
conclusions principales concernant les variables associées à une hausse ou à
une baisse des odds de `HeartDisease = 1` demeurent inchangées.
Ces coefficients représentent des associations conditionnelles dans
l’échantillon et ne doivent pas être interprétés comme des effets causaux.
:::
## Prédictions sur le jeu de test
```{python}
y_pred_ridge = model_ridge.predict(
X_test_ready
)
y_proba_ridge = model_ridge.predict_proba(
X_test_ready
)[:, 1]
```
Ces prédictions utilisent le seuil de décision par défaut de $0{,}5$. Le
jeu de test n’a pas été utilisé pour choisir $C$.
## Indicateurs du modèle Ridge
```{python}
tn_ridge, fp_ridge, fn_ridge, tp_ridge = (
confusion_matrix(
y_test,
y_pred_ridge,
).ravel()
)
sensitivity_ridge = (
tp_ridge
/ (tp_ridge + fn_ridge)
)
specificity_ridge = (
tn_ridge
/ (tn_ridge + fp_ridge)
)
ridge_metrics = pd.Series(
{
"Accuracy":
accuracy_score(y_test, y_pred_ridge),
"Balanced accuracy":
balanced_accuracy_score(
y_test,
y_pred_ridge,
),
"Précision":
precision_score(
y_test,
y_pred_ridge,
),
"Sensibilité / Recall":
sensitivity_ridge,
"Spécificité":
specificity_ridge,
"F1-score":
f1_score(y_test, y_pred_ridge),
"ROC-AUC":
roc_auc_score(
y_test,
y_proba_ridge,
),
"Average Precision":
average_precision_score(
y_test,
y_proba_ridge,
),
},
name="Régression Ridge",
)
display(
ridge_metrics
.to_frame("score")
.round(3)
)
```
### Rapport de classification
```{python}
print(
classification_report(
y_test,
y_pred_ridge,
target_names=[
"Absence de maladie",
"Présence de maladie",
],
digits=3,
)
)
```
### Matrice de confusion
```{python}
#| label: fig-confusion-ridge
#| fig-cap: "Matrice de confusion de la régression logistique Ridge"
ConfusionMatrixDisplay.from_predictions(
y_test,
y_pred_ridge,
display_labels=[
"Absence",
"Présence",
],
)
plt.title(
"Matrice de confusion — Régression Ridge"
)
plt.tight_layout()
plt.show()
```
::: {.callout-important title="Interprétation de la matrice de confusion"}
- vrais positifs : `{python} tp_ridge` ;
- vrais négatifs : `{python} tn_ridge` ;
- faux positifs : `{python} fp_ridge` ;
- faux négatifs : `{python} fn_ridge`.
:::
### Courbe ROC
```{python}
#| label: fig-roc-ridge
#| fig-cap: "Courbe ROC de la régression logistique Ridge"
ridge_roc_auc = roc_auc_score(
y_test,
y_proba_ridge,
)
RocCurveDisplay.from_predictions(
y_test,
y_proba_ridge,
name=(
"Ridge — "
f"AUC = {ridge_roc_auc:.3f}"
),
)
plt.plot(
[0, 1],
[0, 1],
linestyle="--",
label="Modèle aléatoire",
)
plt.title("Courbe ROC — Ridge")
plt.xlabel("Taux de faux positifs")
plt.ylabel("Taux de vrais positifs")
plt.legend()
plt.tight_layout()
plt.show()
```
### Courbe précision-rappel
```{python}
#| label: fig-pr-ridge
#| fig-cap: "Courbe précision-rappel de la régression logistique Ridge"
ridge_average_precision = (
average_precision_score(
y_test,
y_proba_ridge,
)
)
PrecisionRecallDisplay.from_predictions(
y_test,
y_proba_ridge,
name=(
"Ridge — "
f"AP = {ridge_average_precision:.3f}"
),
)
plt.title(
"Courbe précision-rappel — Ridge"
)
plt.xlabel("Rappel")
plt.ylabel("Précision")
plt.legend()
plt.tight_layout()
plt.show()
```
### Comparaison avec le premier modèle
```{python}
model_comparison = pd.concat(
[
logistic_metrics.rename(
"Non pénalisée"
),
ridge_metrics.rename(
"Ridge"
),
],
axis=1,
)
model_comparison["Différence Ridge"] = (
model_comparison["Ridge"]
-
model_comparison["Non pénalisée"]
)
display(model_comparison.round(3))
```
::: {.callout-important title="Comparaison entre la régression simple et Ridge"}
La régression logistique Ridge produit exactement la même matrice de
confusion que le modèle non pénalisé au seuil de décision de $0{,}5$ :
- 68 vrais négatifs ;
- 14 faux positifs ;
- 7 faux négatifs ;
- 95 vrais positifs.
Les principales métriques de classification sont donc identiques :
l’accuracy est de 88,6 %, la sensibilité de 93,1 %, la spécificité de
82,9 % et le score F1 de 0,900.
Ridge améliore très légèrement la ROC-AUC, qui passe de 0,929 à 0,930, ainsi
que l’Average Precision, qui passe de 0,936 à 0,937. Ces écarts de 0,001 sont
toutefois très faibles et ne permettent pas de conclure à une amélioration
prédictive substantielle sur ce jeu de test.
La régularisation L2 a principalement pour effet de réduire légèrement
l’amplitude des coefficients et d’améliorer leur stabilité, sans modifier les
prédictions binaires obtenues au seuil de 0,5.
À ce stade, Ridge ne semble donc pas supérieur au modèle non pénalisé en
termes de classification. Son intérêt réside surtout dans la régularisation
des coefficients et dans une meilleure robustesse potentielle face à la
multicolinéarité ou à de nouveaux échantillons.
:::
# Régression logistique Lasso
## Principe de la régularisation L1
La régression logistique Lasso ajoute à la fonction de perte une pénalité
proportionnelle à la somme des valeurs absolues des coefficients :
$$
\widehat{\beta}^{\,\text{Lasso}}
=
\arg\min_{\beta}
\left[
-\ell(\beta)
+
\lambda
\sum_{j=1}^{p}
|\beta_j|
\right].
$$
Contrairement à Ridge, la pénalisation Lasso peut ramener certains
coefficients exactement à zéro. Elle réalise ainsi une forme de sélection
automatique des variables.
Dans scikit-learn, la relation entre $C$ et la force de régularisation est
inverse :
$$
C \propto \frac{1}{\lambda}.
$$
Ainsi :
- un petit $C$ correspond à une forte régularisation ;
- un grand $C$ correspond à une faible régularisation ;
- une forte régularisation augmente généralement le nombre de coefficients
nuls.
## Modèle Lasso de base
```{python}
model_lasso_base = LogisticRegression(
C=1.0,
l1_ratio=1,
solver="saga",
max_iter=10000,
random_state=RANDOM_STATE,
)
```
`l1_ratio=1` définit une pénalisation L1 pure. Le solveur `saga` est utilisé
car il prend en charge la pénalisation L1. :contentReference[oaicite:1]{index=1}
## Recherche de la meilleure valeur de $C$
La valeur de $C$ est choisie par validation croisée stratifiée uniquement
sur le jeu d’apprentissage. Le jeu de test reste réservé à l’évaluation
finale. `GridSearchCV` teste les paramètres dans les plis de validation puis
réentraîne automatiquement le meilleur estimateur lorsque `refit` est
activé. :contentReference[oaicite:2]{index=2}
```{python}
lasso_parameter_grid = {
"C": np.logspace(-4, 4, 17),
}
lasso_grid_search = GridSearchCV(
estimator=model_lasso_base,
param_grid=lasso_parameter_grid,
scoring={
"roc_auc": "roc_auc",
"accuracy": "accuracy",
"balanced_accuracy": "balanced_accuracy",
"precision": "precision",
"recall": "recall",
"f1": "f1",
},
refit="roc_auc",
cv=cv,
n_jobs=-1,
return_train_score=True,
)
lasso_grid_search.fit(
X_train_ready,
y_train,
)
```
## Meilleur hyperparamètre
```{python}
best_lasso_c = lasso_grid_search.best_params_["C"]
best_lasso_cv_auc = lasso_grid_search.best_score_
model_lasso = lasso_grid_search.best_estimator_
print(f"Meilleure valeur de C : {best_lasso_c:.6g}")
print(
"ROC-AUC moyenne en validation croisée : "
f"{best_lasso_cv_auc:.3f}"
)
```
::: {.callout-note title="Interprétation"}
La meilleure valeur sélectionnée par validation croisée est :
$$
C = 1.
$$
Cette valeur correspond à une régularisation L1 d’intensité intermédiaire :
le modèle n’est ni fortement contraint, comme ce serait le cas avec une très
petite valeur de $C$, ni presque non pénalisé, comme avec une valeur très
élevée.
La ROC-AUC moyenne obtenue en validation croisée est de :
$$
0{,}925.
$$
Cette performance est très proche de celle observée pour le modèle Ridge,
dont la ROC-AUC moyenne était également voisine de $0{,}925$. À ce stade,
les deux régularisations présentent donc une capacité de discrimination
comparable sur les données d’apprentissage.
Le choix de $C=1$ suggère que la validation croisée accepte une
régularisation L1 modérée. Le modèle Lasso peut ainsi réduire certains
coefficients, voire en annuler certains, sans dégrader fortement la
performance prédictive.
Le nombre exact de variables éliminées doit être déterminé à partir des
coefficients estimés. Si plusieurs coefficients sont nuls, Lasso fournit un
modèle plus parcimonieux que Ridge. Si aucun coefficient n’est nul, cela
signifie que cette intensité de régularisation ne suffit pas à effectuer une
sélection stricte des variables.
:::
## Résultats de la validation croisée
```{python}
lasso_cv_results = pd.DataFrame(
lasso_grid_search.cv_results_
)
lasso_cv_summary = (
lasso_cv_results[
[
"param_C",
"mean_train_roc_auc",
"mean_test_roc_auc",
"std_test_roc_auc",
"mean_test_accuracy",
"mean_test_balanced_accuracy",
"mean_test_precision",
"mean_test_recall",
"mean_test_f1",
"rank_test_roc_auc",
]
]
.sort_values("param_C")
.reset_index(drop=True)
)
display(lasso_cv_summary.round(4))
```
## Courbe de validation de $C$
```{python}
#| label: fig-lasso-validation
#| fig-cap: "Performance du modèle Lasso selon la valeur de C"
lasso_cv_plot = lasso_cv_summary.copy()
lasso_cv_plot["param_C"] = (
lasso_cv_plot["param_C"]
.astype(float)
)
plt.figure(figsize=(8, 5))
plt.semilogx(
lasso_cv_plot["param_C"],
lasso_cv_plot["mean_train_roc_auc"],
marker="o",
label="Apprentissage",
)
plt.semilogx(
lasso_cv_plot["param_C"],
lasso_cv_plot["mean_test_roc_auc"],
marker="o",
label="Validation",
)
plt.axvline(
best_lasso_c,
linestyle="--",
label=f"Meilleur C = {best_lasso_c:.4g}",
)
plt.xlabel("C")
plt.ylabel("ROC-AUC moyenne")
plt.title("Choix de la force de régularisation Lasso")
plt.legend()
plt.tight_layout()
plt.show()
```
::: {.callout-note title="Interprétation de la courbe"}
La ROC-AUC de validation est maximale pour :
$$
C = 1.
$$
Pour les très petites valeurs de $C$, approximativement inférieures à
$10^{-2}$, la régularisation L1 est très forte. Les performances restent
proches de $0{,}5$, ce qui indique que le modèle est alors presque incapable
de discriminer les deux classes. Cette zone correspond à un sous-ajustement
important : les coefficients sont trop fortement contraints, voire annulés.
Lorsque $C$ augmente entre environ $10^{-2}$ et $10^{-1}$, la ROC-AUC
progresse rapidement. Le modèle récupère progressivement suffisamment de
variables et de flexibilité pour améliorer fortement sa capacité de
classification.
À partir de $C \approx 0{,}1$, la courbe entre dans un plateau autour de
$0{,}92$ à $0{,}925$. Le maximum obtenu à $C=1$ est donc peu marqué :
plusieurs valeurs proches produisent des performances de validation presque
identiques.
Les courbes d’apprentissage et de validation sont très proches sur toute la
zone optimale. Leur faible écart ne met pas en évidence de surajustement
important. Pour les grandes valeurs de $C$, les performances restent
stables au lieu de se dégrader, ce qui indique que la diminution de la
régularisation n’entraîne pas ici de surapprentissage visible.
Le choix de $C=1$ constitue ainsi un compromis raisonnable entre
performance prédictive et parcimonie. Toutefois, comme la zone optimale forme
un plateau, le choix exact de $C$ est peu sensible : des valeurs voisines,
comme $0{,}3$, $1$ ou $3$, donneraient probablement des performances
très similaires.
:::
### Nombre de coefficients sélectionnés selon $C$
Le graphique suivant montre comment la régularisation L1 élimine
progressivement les variables.
```{python}
lasso_path_rows = []
for c_value in np.logspace(-4, 4, 17):
current_model = LogisticRegression(
C=c_value,
l1_ratio=1,
solver="saga",
max_iter=10000,
random_state=RANDOM_STATE,
)
current_model.fit(
X_train_ready,
y_train,
)
current_coefficients = current_model.coef_[0]
lasso_path_rows.append(
{
"C": c_value,
"nombre_coefficients_non_nuls":
np.count_nonzero(
np.abs(current_coefficients) > 1e-8
),
"nombre_coefficients_nuls":
np.sum(
np.abs(current_coefficients) <= 1e-8
),
}
)
lasso_sparsity = pd.DataFrame(
lasso_path_rows
)
display(lasso_sparsity)
```
```{python}
#| label: fig-lasso-sparsity
#| fig-cap: "Nombre de coefficients conservés selon la valeur de C"
plt.figure(figsize=(8, 5))
plt.semilogx(
lasso_sparsity["C"],
lasso_sparsity["nombre_coefficients_non_nuls"],
marker="o",
)
plt.axvline(
best_lasso_c,
linestyle="--",
label=f"Meilleur C = {best_lasso_c:.4g}",
)
plt.xlabel("C")
plt.ylabel("Nombre de coefficients non nuls")
plt.title("Parcimonie du modèle Lasso")
plt.legend()
plt.tight_layout()
plt.show()
```
::: {.callout-note title="Interprétation de la parcimonie"}
Lorsque $C$ est faible, la régularisation est forte et davantage de
coefficients sont ramenés à zéro. Lorsque $C$ augmente, le modèle conserve
progressivement plus de variables.
Le meilleur compromis doit conserver une bonne performance de validation
tout en utilisant un nombre raisonnable de variables.
:::
### Coefficients du meilleur modèle Lasso
```{python}
lasso_coefficient_table = pd.DataFrame(
{
"variable": X_train_ready.columns,
"coefficient_lasso": model_lasso.coef_[0],
}
)
lasso_coefficient_table["odds_ratio_lasso"] = np.exp(
lasso_coefficient_table["coefficient_lasso"]
)
lasso_coefficient_table["coefficient_absolu"] = (
lasso_coefficient_table["coefficient_lasso"]
.abs()
)
lasso_coefficient_table["selectionnee"] = (
lasso_coefficient_table["coefficient_absolu"]
> 1e-8
)
lasso_coefficient_table = (
lasso_coefficient_table
.sort_values(
"coefficient_absolu",
ascending=False,
)
)
print(
f"Intercept Lasso : "
f"{model_lasso.intercept_[0]:.4f}"
)
display(
lasso_coefficient_table[
[
"variable",
"coefficient_lasso",
"odds_ratio_lasso",
"selectionnee",
]
].round(4)
)
```
### Variables conservées et éliminées
```{python}
selected_lasso_features = (
lasso_coefficient_table.loc[
lasso_coefficient_table["selectionnee"],
"variable",
]
.tolist()
)
removed_lasso_features = (
lasso_coefficient_table.loc[
~lasso_coefficient_table["selectionnee"],
"variable",
]
.tolist()
)
print(
"Nombre de variables conservées :",
len(selected_lasso_features),
)
print(
"Nombre de variables éliminées :",
len(removed_lasso_features),
)
print("\nVariables conservées :")
print(selected_lasso_features)
print("\nVariables éliminées :")
print(removed_lasso_features)
```
::: {.callout-important title="Interprétation de la sélection"}
Toutes les variables sont conservées.
:::
### Comparaison des coefficients des trois modèles
```{python}
coefficient_comparison_three = (
coefficient_table[
[
"variable",
"coefficient",
]
]
.rename(
columns={
"coefficient": "non_penalise",
}
)
.merge(
ridge_coefficient_table[
[
"variable",
"coefficient_ridge",
]
],
on="variable",
how="inner",
)
.merge(
lasso_coefficient_table[
[
"variable",
"coefficient_lasso",
]
],
on="variable",
how="inner",
)
)
display(
coefficient_comparison_three
.sort_values(
"coefficient_lasso",
key=lambda values: values.abs(),
ascending=False,
)
.round(4)
)
```
```{python}
coefficient_comparison_three_plot = (
coefficient_comparison_three
.set_index("variable")
.sort_values("non_penalise")
)
coefficient_comparison_three_plot.plot(
kind="barh",
figsize=(11, 9),
)
plt.axvline(0, linewidth=1)
plt.xlabel("Coefficient estimé")
plt.ylabel("Variable")
plt.title(
"Comparaison des coefficients : "
"non pénalisé, Ridge et Lasso"
)
plt.tight_layout()
plt.show()
```
::: {.callout-note title="Interprétation comparative"}
Les trois modèles conservent globalement les mêmes signes de coefficients, ce
qui indique une bonne stabilité de la direction des associations estimées.
La régression Ridge réduit légèrement l’amplitude de la plupart des
coefficients, sans en annuler. La régression Lasso produit généralement une
contraction plus forte, en particulier pour `ChestPainType_TA`,
`RestingECG_Normal`, `RestingECG_ST`, `FastingBS_1` et `ST_Slope_Flat`.
Les variables conservant les coefficients les plus importants dans les trois
modèles sont :
- `ChestPainType_NAP` ;
- `ST_Slope_Up` ;
- `ChestPainType_ATA` ;
- `ChestPainType_TA` ;
- `Sex_M` ;
- `FastingBS_1`.
Ces variables semblent donc robustement associées à la prédiction de
`HeartDisease = 1`.
Le modèle Lasso ne ramène ici aucun coefficient exactement à zéro. Il ne
réalise donc pas de sélection stricte de variables avec la valeur optimale
$C=1$, mais il produit tout de même un modèle plus parcimonieux en réduisant
davantage certains effets.
Quelques coefficients augmentent légèrement avec Lasso, notamment
`ST_Slope_Up` et `Age`. Cela est possible car tous les coefficients sont
réestimés conjointement : la pénalisation ne réduit pas nécessairement chaque
coefficient de façon monotone.
Globalement, les trois modèles conduisent à des conclusions similaires. Ridge
apporte surtout de la stabilité, tandis que Lasso simplifie davantage les
coefficients sans modifier profondément l’interprétation du modèle.
:::
### Prédictions sur le jeu de test
```{python}
y_pred_lasso = model_lasso.predict(
X_test_ready
)
y_proba_lasso = model_lasso.predict_proba(
X_test_ready
)[:, 1]
```
Ces prédictions utilisent le seuil par défaut de $0{,}5$.
### Indicateurs du modèle Lasso
```{python}
tn_lasso, fp_lasso, fn_lasso, tp_lasso = (
confusion_matrix(
y_test,
y_pred_lasso,
).ravel()
)
sensitivity_lasso = (
tp_lasso
/ (tp_lasso + fn_lasso)
)
specificity_lasso = (
tn_lasso
/ (tn_lasso + fp_lasso)
)
lasso_metrics = pd.Series(
{
"Accuracy":
accuracy_score(
y_test,
y_pred_lasso,
),
"Balanced accuracy":
balanced_accuracy_score(
y_test,
y_pred_lasso,
),
"Précision":
precision_score(
y_test,
y_pred_lasso,
),
"Sensibilité / Recall":
sensitivity_lasso,
"Spécificité":
specificity_lasso,
"F1-score":
f1_score(
y_test,
y_pred_lasso,
),
"ROC-AUC":
roc_auc_score(
y_test,
y_proba_lasso,
),
"Average Precision":
average_precision_score(
y_test,
y_proba_lasso,
),
},
name="Régression Lasso",
)
display(
lasso_metrics
.to_frame("score")
.round(3)
)
```
### Rapport de classification
```{python}
print(
classification_report(
y_test,
y_pred_lasso,
target_names=[
"Absence de maladie",
"Présence de maladie",
],
digits=3,
)
)
```
### Matrice de confusion
```{python}
#| label: fig-confusion-lasso
#| fig-cap: "Matrice de confusion de la régression logistique Lasso"
ConfusionMatrixDisplay.from_predictions(
y_test,
y_pred_lasso,
display_labels=[
"Absence",
"Présence",
],
)
plt.title(
"Matrice de confusion — Régression Lasso"
)
plt.tight_layout()
plt.show()
```
::: {.callout-important title="Interprétation de la matrice de confusion"}
- vrais positifs : `{python} tp_lasso` ;
- vrais négatifs : `{python} tn_lasso` ;
- faux positifs : `{python} fp_lasso` ;
- faux négatifs : `{python} fn_lasso`.
La régression logistique Lasso obtient les mêmes performances de
classification que le modèle non pénalisé au seuil de décision de $0{,}5$.
L’accuracy est de 88,6 %, la sensibilité de 93,1 %, la spécificité de
82,9 % et le score F1 de 0,900. Le modèle conserve donc une forte capacité
à détecter les observations appartenant à la classe `HeartDisease = 1`,
tout en produisant relativement peu de faux négatifs.
La ROC-AUC de 0,929 et l’Average Precision de 0,936 indiquent également une
bonne capacité de discrimination sur l’ensemble des seuils.
Cependant, avec la valeur optimale $C=1$, Lasso ne ramène aucun coefficient
exactement à zéro : les 15 variables transformées sont conservées. Il ne
réalise donc pas de sélection stricte de variables dans cette configuration.
Lasso réduit néanmoins l’amplitude de plusieurs coefficients, mais cette
contraction ne modifie pas les prédictions finales sur le jeu de test. Son
apport principal est donc ici la régularisation des coefficients, et non une
amélioration des performances ou une simplification effective du modèle.
:::
### Courbe ROC
```{python}
#| label: fig-roc-lasso
#| fig-cap: "Courbe ROC de la régression logistique Lasso"
lasso_roc_auc = roc_auc_score(
y_test,
y_proba_lasso,
)
RocCurveDisplay.from_predictions(
y_test,
y_proba_lasso,
name=(
"Lasso — "
f"AUC = {lasso_roc_auc:.3f}"
),
)
plt.plot(
[0, 1],
[0, 1],
linestyle="--",
label="Modèle aléatoire",
)
plt.title("Courbe ROC — Lasso")
plt.xlabel("Taux de faux positifs")
plt.ylabel("Taux de vrais positifs")
plt.legend()
plt.tight_layout()
plt.show()
```
### Courbe précision-rappel
```{python}
#| label: fig-pr-lasso
#| fig-cap: "Courbe précision-rappel de la régression logistique Lasso"
lasso_average_precision = (
average_precision_score(
y_test,
y_proba_lasso,
)
)
PrecisionRecallDisplay.from_predictions(
y_test,
y_proba_lasso,
name=(
"Lasso — "
f"AP = {lasso_average_precision:.3f}"
),
)
plt.title(
"Courbe précision-rappel — Lasso"
)
plt.xlabel("Rappel")
plt.ylabel("Précision")
plt.legend()
plt.tight_layout()
plt.show()
```
### Comparaison des trois modèles
```{python}
model_comparison = pd.concat(
[
logistic_metrics.rename(
"Non pénalisée"
),
ridge_metrics.rename(
"Ridge"
),
lasso_metrics.rename(
"Lasso"
),
],
axis=1,
)
model_comparison[
"Différence Lasso vs non pénalisée"
] = (
model_comparison["Lasso"]
-
model_comparison["Non pénalisée"]
)
model_comparison[
"Différence Lasso vs Ridge"
] = (
model_comparison["Lasso"]
-
model_comparison["Ridge"]
)
display(model_comparison.round(3))
```
::: {.callout-important title="Conclusion du modèle Lasso"}
La régression logistique Lasso conserve exactement les mêmes performances de
classification que les modèles non pénalisé et Ridge au seuil de décision de
$0{,}5$.
L’accuracy reste égale à 88,6 %, la sensibilité à 93,1 %, la spécificité à
82,9 % et le score F1 à 0,900. Le nombre de faux négatifs n’augmente donc pas :
le modèle continue de détecter correctement 95 cas positifs sur 102 et n’en
manque que 7.
La ROC-AUC de Lasso atteint 0,929, soit une valeur identique à celle du modèle
non pénalisé et inférieure de seulement 0,001 à celle de Ridge. L’Average
Precision suit la même tendance, avec un écart également négligeable de 0,001
par rapport à Ridge.
Avec la valeur optimale $C=1$, aucun coefficient n’est toutefois ramené
exactement à zéro. Les 15 variables transformées sont conservées. Lasso ne
produit donc pas ici un modèle plus simple au sens d’une sélection stricte de
variables.
En conclusion, Lasso régularise légèrement les coefficients sans dégrader les
performances, mais il n’apporte ni gain prédictif net ni amélioration notable
de l’interprétabilité par rapport aux deux modèles précédents. Ridge conserve
un très léger avantage sur les métriques calculées à partir des probabilités,
mais cet écart reste trop faible pour être considéré comme substantiel.
:::
# Régression logistique Elastic Net
## Principe de la régularisation Elastic Net
Elastic Net combine une pénalisation L1, utilisée par Lasso, et une
pénalisation L2, utilisée par Ridge :
$$
\widehat{\beta}^{\,\text{Elastic Net}}
=
\arg\min_{\beta}
\left[
-\ell(\beta)
+
\lambda
\left(
\alpha\sum_{j=1}^{p}|\beta_j|
+
\frac{1-\alpha}{2}
\sum_{j=1}^{p}\beta_j^2
\right)
\right].
$$
Le paramètre $\alpha$, représenté par `l1_ratio` dans scikit-learn,
contrôle le mélange entre les deux pénalités :
- `l1_ratio = 0` : pénalisation Ridge pure ;
- `l1_ratio = 1` : pénalisation Lasso pure ;
- `0 < l1_ratio < 1` : combinaison Elastic Net.
Le paramètre $C$ est inversement lié à la force globale de régularisation :
$$
C \propto \frac{1}{\lambda}.
$$
Elastic Net peut donc à la fois réduire l’amplitude des coefficients et,
lorsque la composante L1 est suffisamment forte, ramener certains
coefficients à zéro.
## Définition du modèle Elastic Net
```{python}
model_elastic_base = LogisticRegression(
C=1.0,
l1_ratio=0.5,
solver="saga",
max_iter=10000,
random_state=RANDOM_STATE,
)
```
## Recherche conjointe de $C$ et de `l1_ratio`
Deux hyperparamètres doivent être optimisés :
- $C$, qui contrôle la force globale de régularisation ;
- `l1_ratio`, qui contrôle le compromis entre L1 et L2.
```{python}
elastic_parameter_grid = {
"C": np.logspace(-3, 3, 13),
"l1_ratio": [
0.1,
0.25,
0.5,
0.75,
0.9,
],
}
```
```{python}
elastic_grid_search = GridSearchCV(
estimator=model_elastic_base,
param_grid=elastic_parameter_grid,
scoring={
"roc_auc": "roc_auc",
"accuracy": "accuracy",
"balanced_accuracy": "balanced_accuracy",
"precision": "precision",
"recall": "recall",
"f1": "f1",
},
refit="roc_auc",
cv=cv,
n_jobs=-1,
return_train_score=True,
)
elastic_grid_search.fit(
X_train_ready,
y_train,
)
```
Avec `refit="roc_auc"`, la meilleure combinaison est choisie selon la
ROC-AUC moyenne de validation, puis réentraînée sur l’ensemble du jeu
d’apprentissage. :contentReference[oaicite:1]{index=1}
## Meilleurs hyperparamètres
```{python}
best_elastic_c = (
elastic_grid_search.best_params_["C"]
)
best_elastic_l1_ratio = (
elastic_grid_search.best_params_["l1_ratio"]
)
best_elastic_cv_auc = (
elastic_grid_search.best_score_
)
model_elastic = (
elastic_grid_search.best_estimator_
)
print(
f"Meilleure valeur de C : "
f"{best_elastic_c:.6g}"
)
print(
f"Meilleur l1_ratio : "
f"{best_elastic_l1_ratio:.2f}"
)
print(
"ROC-AUC moyenne en validation croisée : "
f"{best_elastic_cv_auc:.3f}"
)
```
::: {.callout-note title="Interprétation"}
La meilleure combinaison d’hyperparamètres retenue par validation croisée est :
$$
C = 1
\qquad \text{et} \qquad
\texttt{l1\_ratio} = 0{,}90.
$$
La valeur $C=1$ correspond à une intensité de régularisation intermédiaire.
Le modèle n’est donc ni fortement contraint, ni presque non pénalisé.
La valeur `l1_ratio = 0.90` indique que la pénalisation Elastic Net est très
majoritairement composée de L1 et seulement faiblement de L2. Le modèle se
comporte donc davantage comme une régression Lasso que comme une régression
Ridge.
La composante L1 représente environ 90 % du mélange de pénalisation, tandis
que la composante L2 en représente environ 10 %. Elastic Net privilégie ainsi
la réduction forte, voire l’annulation, de certains coefficients, tout en
conservant une petite composante Ridge susceptible d’améliorer leur stabilité.
La ROC-AUC moyenne obtenue en validation croisée est de :
$$
0{,}925.
$$
Cette performance est pratiquement identique à celles obtenues avec Ridge et
Lasso. Elastic Net n’apporte donc pas, à ce stade, d’amélioration nette de la
capacité de discrimination.
Le choix d’un `l1_ratio` élevé suggère que les données favorisent davantage
le comportement parcimonieux de Lasso que la contraction uniforme des
coefficients propre à Ridge. Il faudra toutefois vérifier si le modèle ramène
réellement certains coefficients à zéro. Si aucun coefficient n’est annulé,
Elastic Net se comportera surtout comme une version légèrement stabilisée de
Lasso, sans sélection stricte de variables.
:::
## Résultats complets de la validation croisée
```{python}
elastic_cv_results = pd.DataFrame(
elastic_grid_search.cv_results_
)
elastic_cv_summary = (
elastic_cv_results[
[
"param_C",
"param_l1_ratio",
"mean_train_roc_auc",
"mean_test_roc_auc",
"std_test_roc_auc",
"mean_test_accuracy",
"mean_test_balanced_accuracy",
"mean_test_precision",
"mean_test_recall",
"mean_test_f1",
"rank_test_roc_auc",
]
]
.sort_values(
[
"rank_test_roc_auc",
"param_C",
"param_l1_ratio",
]
)
.reset_index(drop=True)
)
display(
elastic_cv_summary.head(20).round(4)
)
```
## Tableau des ROC-AUC selon les hyperparamètres
```{python}
elastic_auc_pivot = (
elastic_cv_results
.pivot_table(
index="param_l1_ratio",
columns="param_C",
values="mean_test_roc_auc",
)
)
display(elastic_auc_pivot.round(4))
```
## Carte de validation
```{python}
#| label: fig-elastic-validation
#| fig-cap: "ROC-AUC de validation selon C et l1_ratio"
plt.figure(figsize=(12, 5))
sns.heatmap(
elastic_auc_pivot,
annot=True,
fmt=".3f",
cmap="viridis",
)
plt.title(
"ROC-AUC moyenne — Elastic Net"
)
plt.xlabel("C")
plt.ylabel("l1_ratio")
plt.tight_layout()
plt.show()
```
::: {.callout-note title="Interprétation de la carte"}
La meilleure combinaison d’hyperparamètres est obtenue pour :
$$
C = 1
\qquad \text{et} \qquad
\texttt{l1\_ratio} = 0{,}90,
$$
avec une ROC-AUC moyenne de validation égale à environ $0{,}9253$.
Le `l1_ratio` élevé indique que la solution optimale est plus proche de
Lasso que de Ridge. La composante L1 domine donc nettement la pénalisation,
tandis que la composante L2 joue seulement un rôle complémentaire de
stabilisation.
La carte montre toutefois que la zone optimale n’est pas limitée à une seule
combinaison précise. Pour des valeurs de $C$ comprises approximativement
entre $0{,}3$ et $3$, plusieurs valeurs de `l1_ratio` produisent des
ROC-AUC très proches, généralement comprises entre $0{,}924$ et
$0{,}925$.
Le maximum est donc situé sur un plateau plutôt que sur un pic isolé. Cela
signifie que les performances du modèle sont peu sensibles à de petites
variations de $C$ ou de `l1_ratio` dans cette zone.
Pour les très petites valeurs de $C$, la régularisation est trop forte,
surtout lorsque `l1_ratio` est élevé. La ROC-AUC peut alors tomber jusqu’à
$0{,}5$, ce qui correspond à un modèle sans capacité de discrimination et
traduit un sous-ajustement important.
À l’inverse, lorsque $C$ devient grand, les performances se stabilisent
autour de $0{,}924$. Une régularisation très faible n’apporte donc pas
d’amélioration supplémentaire.
En conclusion, la combinaison $C=1$ et `l1_ratio=0.90` constitue un choix
raisonnable, mais elle n’est pas nettement supérieure aux autres combinaisons
proches. Elastic Net présente ici une zone de performance stable, avec un
comportement proche de Lasso.
:::
## Courbes de validation selon $C$
```{python}
#| label: fig-elastic-curves
#| fig-cap: "Performance Elastic Net selon C et l1_ratio"
plt.figure(figsize=(9, 6))
for ratio in sorted(
elastic_cv_results["param_l1_ratio"].unique()
):
ratio_results = elastic_cv_results[
elastic_cv_results[
"param_l1_ratio"
] == ratio
].copy()
ratio_results = ratio_results.sort_values(
"param_C"
)
plt.semilogx(
ratio_results["param_C"].astype(float),
ratio_results["mean_test_roc_auc"],
marker="o",
label=f"l1_ratio = {ratio}",
)
plt.axvline(
best_elastic_c,
linestyle="--",
label=(
f"Meilleur C = "
f"{best_elastic_c:.4g}"
),
)
plt.xlabel("C")
plt.ylabel("ROC-AUC moyenne de validation")
plt.title(
"Choix des hyperparamètres Elastic Net"
)
plt.legend()
plt.tight_layout()
plt.show()
```
## Coefficients du meilleur modèle
```{python}
elastic_coefficient_table = pd.DataFrame(
{
"variable":
X_train_ready.columns,
"coefficient_elastic":
model_elastic.coef_[0],
}
)
elastic_coefficient_table[
"odds_ratio_elastic"
] = np.exp(
elastic_coefficient_table[
"coefficient_elastic"
]
)
elastic_coefficient_table[
"coefficient_absolu"
] = (
elastic_coefficient_table[
"coefficient_elastic"
].abs()
)
elastic_coefficient_table[
"selectionnee"
] = (
elastic_coefficient_table[
"coefficient_absolu"
] > 1e-8
)
elastic_coefficient_table = (
elastic_coefficient_table
.sort_values(
"coefficient_absolu",
ascending=False,
)
)
print(
"Intercept Elastic Net : "
f"{model_elastic.intercept_[0]:.4f}"
)
display(
elastic_coefficient_table[
[
"variable",
"coefficient_elastic",
"odds_ratio_elastic",
"selectionnee",
]
].round(4)
)
```
## Variables conservées et éliminées
```{python}
selected_elastic_features = (
elastic_coefficient_table.loc[
elastic_coefficient_table[
"selectionnee"
],
"variable",
]
.tolist()
)
removed_elastic_features = (
elastic_coefficient_table.loc[
~elastic_coefficient_table[
"selectionnee"
],
"variable",
]
.tolist()
)
print(
"Nombre de variables conservées :",
len(selected_elastic_features),
)
print(
"Nombre de variables éliminées :",
len(removed_elastic_features),
)
print("\nVariables conservées :")
print(selected_elastic_features)
print("\nVariables éliminées :")
print(removed_elastic_features)
```
::: {.callout-note title="Interprétation des coefficients"}
Le modèle Elastic Net conserve les 15 variables transformées : aucun
coefficient n’est ramené exactement à zéro. Malgré un `l1_ratio` élevé de
$0{,}90$, la régularisation choisie n’est donc pas assez forte pour produire
une sélection stricte de variables.
Les coefficients les plus importants en valeur absolue concernent :
- `ChestPainType_NAP` ;
- `ChestPainType_ATA` ;
- `ST_Slope_Up` ;
- `ChestPainType_TA` ;
- `Sex_M` ;
- `FastingBS_1`.
Ces variables restent les principales contributrices à la prédiction, comme
dans les modèles non pénalisé, Ridge et Lasso.
Les signes des coefficients sont également stables par rapport aux modèles
précédents. Les modalités `Sex_M`, `FastingBS_1`, `ExerciseAngina_Y`,
`ST_Slope_Flat`, `Oldpeak` et `Age` restent associées à une augmentation des
odds de `HeartDisease = 1`. À l’inverse, les modalités de
`ChestPainType`, `ST_Slope_Up`, `Cholesterol`, `MaxHR` et certaines modalités
de `RestingECG` restent associées à une diminution des odds.
Les valeurs des coefficients Elastic Net sont très proches de celles obtenues
avec Lasso. Cela est cohérent avec le meilleur `l1_ratio = 0.90`, qui donne
une pénalisation dominée par la composante L1. La faible composante L2 apporte
néanmoins une légère stabilisation des coefficients.
Par exemple :
- `Sex_M` possède un odds ratio d’environ 3,06 ;
- `FastingBS_1` multiplie les odds par environ 2,52 ;
- `ExerciseAngina_Y` et `ST_Slope_Flat` les multiplient par environ 2,42 ;
- `ChestPainType_NAP` présente un odds ratio d’environ 0,17 par rapport à la
catégorie de référence.
Globalement, Elastic Net modifie surtout l’amplitude des coefficients sans
changer leur hiérarchie ni leur interprétation générale. Il se comporte ici
comme une version légèrement stabilisée de Lasso, sans véritable sélection
de variables.
Ces coefficients décrivent des associations conditionnelles dans
l’échantillon et ne doivent pas être interprétés comme des effets causaux.
:::
## Comparaison des coefficients des quatre modèles
```{python}
coefficient_comparison_four = (
coefficient_table[
[
"variable",
"coefficient",
]
]
.rename(
columns={
"coefficient": "non_penalise",
}
)
.merge(
ridge_coefficient_table[
[
"variable",
"coefficient_ridge",
]
],
on="variable",
how="inner",
)
.merge(
lasso_coefficient_table[
[
"variable",
"coefficient_lasso",
]
],
on="variable",
how="inner",
)
.merge(
elastic_coefficient_table[
[
"variable",
"coefficient_elastic",
]
],
on="variable",
how="inner",
)
)
display(
coefficient_comparison_four
.sort_values(
"coefficient_elastic",
key=lambda values: values.abs(),
ascending=False,
)
.round(4)
)
```
```{python}
coefficient_comparison_four_plot = (
coefficient_comparison_four
.set_index("variable")
.sort_values("non_penalise")
)
coefficient_comparison_four_plot.plot(
kind="barh",
figsize=(12, 10),
)
plt.axvline(0, linewidth=1)
plt.xlabel("Coefficient estimé")
plt.ylabel("Variable")
plt.title(
"Comparaison des coefficients des quatre modèles"
)
plt.tight_layout()
plt.show()
```
::: {.callout-note title="Interprétation comparative"}
Les coefficients d’Elastic Net sont très proches de ceux obtenus avec Lasso.
Cela est cohérent avec la valeur optimale `l1_ratio = 0.90`, qui donne une
pénalisation très majoritairement L1 et seulement faiblement L2.
Elastic Net ressemble donc davantage au modèle Lasso qu’au modèle Ridge.
Les variables les plus fortement réduites par rapport au modèle non pénalisé
sont notamment :
- `ChestPainType_TA` ;
- `RestingECG_ST` ;
- `RestingECG_Normal` ;
- `FastingBS_1` ;
- `ST_Slope_Flat` ;
- `Sex_M`.
Par exemple, le coefficient de `ChestPainType_TA` passe de `-1.5116` dans le
modèle non pénalisé à `-1.1969` avec Elastic Net. De même,
`RestingECG_ST` passe de `-0.4249` à `-0.1697`.
Aucun coefficient n’est toutefois ramené exactement à zéro. Les 15 variables
transformées sont conservées. Dans cette configuration, Elastic Net ne réalise
donc pas de sélection stricte de variables.
La hiérarchie générale des variables reste stable entre les quatre modèles.
Les coefficients les plus importants en valeur absolue concernent toujours :
- `ChestPainType_NAP` ;
- `ChestPainType_ATA` ;
- `ST_Slope_Up` ;
- `ChestPainType_TA` ;
- `Sex_M` ;
- `FastingBS_1`.
Les signes restent également identiques, ce qui montre que la direction des
associations estimées est robuste à la régularisation.
Globalement, Elastic Net combine une contraction des coefficients proche de
Lasso avec une légère stabilisation apportée par la composante Ridge. Il ne
modifie cependant ni la structure générale du modèle, ni la sélection des
variables.
:::
## Prédictions sur le jeu de test
```{python}
y_pred_elastic = model_elastic.predict(
X_test_ready
)
y_proba_elastic = (
model_elastic.predict_proba(
X_test_ready
)[:, 1]
)
```
Les classes sont calculées avec le seuil par défaut de $0{,}5$.
## Indicateurs du modèle Elastic Net
```{python}
tn_elastic, fp_elastic, fn_elastic, tp_elastic = (
confusion_matrix(
y_test,
y_pred_elastic,
).ravel()
)
sensitivity_elastic = (
tp_elastic
/ (tp_elastic + fn_elastic)
)
specificity_elastic = (
tn_elastic
/ (tn_elastic + fp_elastic)
)
elastic_metrics = pd.Series(
{
"Accuracy":
accuracy_score(
y_test,
y_pred_elastic,
),
"Balanced accuracy":
balanced_accuracy_score(
y_test,
y_pred_elastic,
),
"Précision":
precision_score(
y_test,
y_pred_elastic,
),
"Sensibilité / Recall":
sensitivity_elastic,
"Spécificité":
specificity_elastic,
"F1-score":
f1_score(
y_test,
y_pred_elastic,
),
"ROC-AUC":
roc_auc_score(
y_test,
y_proba_elastic,
),
"Average Precision":
average_precision_score(
y_test,
y_proba_elastic,
),
},
name="Elastic Net",
)
display(
elastic_metrics
.to_frame("score")
.round(3)
)
```
## Rapport de classification
```{python}
print(
classification_report(
y_test,
y_pred_elastic,
target_names=[
"Absence de maladie",
"Présence de maladie",
],
digits=3,
)
)
```
## Matrice de confusion
```{python}
#| label: fig-confusion-elastic
#| fig-cap: "Matrice de confusion de la régression logistique Elastic Net"
ConfusionMatrixDisplay.from_predictions(
y_test,
y_pred_elastic,
display_labels=[
"Absence",
"Présence",
],
)
plt.title(
"Matrice de confusion — Elastic Net"
)
plt.tight_layout()
plt.show()
```
::: {.callout-important title="Interprétation de la matrice de confusion"}
La matrice de confusion du modèle Elastic Net contient :
- vrais positifs : `95` ;
- vrais négatifs : `68` ;
- faux positifs : `14` ;
- faux négatifs : `7`.
Le modèle détecte donc correctement 95 des 102 observations appartenant
réellement à la classe `HeartDisease = 1`. Il ne manque que 7 cas positifs,
ce qui explique la sensibilité élevée de 93,1 %.
Parmi les 82 observations réellement négatives, 68 sont correctement
classées, tandis que 14 sont prédites à tort comme positives. La spécificité
est ainsi de 82,9 %.
Elastic Net privilégie légèrement la détection des cas positifs : il produit
moins de faux négatifs que de faux positifs. Ce comportement peut être
pertinent dans une logique de dépistage, où manquer un cas positif peut être
plus problématique qu’orienter un cas négatif vers un examen complémentaire.
La matrice de confusion est exactement identique à celles obtenues avec la
régression non pénalisée, Ridge et Lasso. Au seuil de décision de $0{,}5$,
les quatre modèles classent donc les mêmes observations.
Elastic Net n’apporte ainsi aucune amélioration des prédictions binaires sur
le jeu de test. Son intérêt éventuel réside plutôt dans la régularisation et
la stabilité des coefficients que dans une amélioration de la classification.
:::
## Courbe ROC
```{python}
#| label: fig-roc-elastic
#| fig-cap: "Courbe ROC de la régression logistique Elastic Net"
elastic_roc_auc = roc_auc_score(
y_test,
y_proba_elastic,
)
RocCurveDisplay.from_predictions(
y_test,
y_proba_elastic,
name=(
"Elastic Net — "
f"AUC = {elastic_roc_auc:.3f}"
),
)
plt.plot(
[0, 1],
[0, 1],
linestyle="--",
label="Modèle aléatoire",
)
plt.title("Courbe ROC — Elastic Net")
plt.xlabel("Taux de faux positifs")
plt.ylabel("Taux de vrais positifs")
plt.legend()
plt.tight_layout()
plt.show()
```
## Courbe précision-rappel
```{python}
#| label: fig-pr-elastic
#| fig-cap: "Courbe précision-rappel de la régression Elastic Net"
elastic_average_precision = (
average_precision_score(
y_test,
y_proba_elastic,
)
)
PrecisionRecallDisplay.from_predictions(
y_test,
y_proba_elastic,
name=(
"Elastic Net — "
f"AP = "
f"{elastic_average_precision:.3f}"
),
)
plt.title(
"Courbe précision-rappel — Elastic Net"
)
plt.xlabel("Rappel")
plt.ylabel("Précision")
plt.legend()
plt.tight_layout()
plt.show()
```
## Comparaison des quatre régressions logistiques
```{python}
final_model_comparison = pd.concat(
[
logistic_metrics.rename(
"Non pénalisée"
),
ridge_metrics.rename(
"Ridge"
),
lasso_metrics.rename(
"Lasso"
),
elastic_metrics.rename(
"Elastic Net"
),
],
axis=1,
)
final_model_comparison[
"Différence Elastic vs non pénalisée"
] = (
final_model_comparison["Elastic Net"]
-
final_model_comparison["Non pénalisée"]
)
final_model_comparison[
"Différence Elastic vs Ridge"
] = (
final_model_comparison["Elastic Net"]
-
final_model_comparison["Ridge"]
)
final_model_comparison[
"Différence Elastic vs Lasso"
] = (
final_model_comparison["Elastic Net"]
-
final_model_comparison["Lasso"]
)
display(
final_model_comparison.round(3)
)
```
::: {.callout-important title="Conclusion du modèle Elastic Net"}
Le modèle Elastic Net retient la combinaison suivante :
$$
C = 1
\qquad \text{et} \qquad
\texttt{l1\_ratio} = 0{,}90.
$$
Cette configuration correspond à une régularisation dominée par la composante
L1. Le modèle se comporte donc davantage comme Lasso que comme Ridge, tout en
conservant une faible composante L2 destinée à stabiliser les coefficients.
Sur le jeu de test, Elastic Net n’améliore pas les performances de
classification. L’accuracy, la balanced accuracy, la précision, la
sensibilité, la spécificité et le score F1 sont strictement identiques à ceux
des trois modèles précédents.
La matrice de confusion reste également inchangée :
- 95 vrais positifs ;
- 68 vrais négatifs ;
- 14 faux positifs ;
- 7 faux négatifs.
La sensibilité demeure donc élevée, à 93,1 %, et le nombre de faux négatifs
n’augmente pas.
La ROC-AUC atteint 0,929 et l’Average Precision 0,936. Ces résultats sont
identiques à ceux du modèle non pénalisé et de Lasso, et inférieurs de
seulement 0,001 à ceux de Ridge. Cet écart est trop faible pour conclure à une
supériorité réelle de Ridge.
Elastic Net ne ramène par ailleurs aucun coefficient exactement à zéro : les
15 variables transformées sont conservées. Il ne produit donc pas de modèle
plus parcimonieux dans cette configuration.
En conclusion, Elastic Net conserve de bonnes performances et des coefficients
stables, mais il n’apporte ni amélioration prédictive nette, ni sélection de
variables. Son comportement est très proche de Lasso, ce qui est cohérent avec
la valeur élevée de `l1_ratio`.
Parmi les quatre modèles, aucun ne se distingue clairement par ses performances
de classification. Ridge présente un avantage très léger sur la ROC-AUC et
l’Average Precision, tandis que les autres modèles donnent des résultats
pratiquement équivalents.
:::
# Arbre de décision
## Principe du modèle
Un arbre de décision divise récursivement l’espace des variables explicatives
en régions de plus en plus homogènes.
À chaque nœud, le modèle recherche une variable $X_j$ et un seuil $s$
permettant de séparer les observations en deux groupes :
$$
R_1(j,s)=\{X \mid X_j \leq s\},
$$
et :
$$
R_2(j,s)=\{X \mid X_j > s\}.
$$
Pour une classification binaire, l’impureté de Gini d’un nœud $m$ est :
$$
G_m
=
1-\sum_{k=1}^{K}p_{mk}^{\,2},
$$
où $p_{mk}$ est la proportion d’observations de la classe $k$ dans le
nœud $m$.
Dans le cas binaire :
$$
G_m
=
1-p_m^2-(1-p_m)^2.
$$
Une impureté proche de zéro indique que le nœud contient presque uniquement
des observations d’une même classe.
## Données utilisées
Les arbres de décision ne nécessitent pas de standardisation. Nous utilisons
donc les variables numériques dans leurs unités d’origine, accompagnées des
variables catégorielles encodées.
```{python}
X_train_encoded = pd.concat(
[
X_train[numeric_features],
X_train_categorical_encoded,
],
axis=1,
)
X_test_encoded = pd.concat(
[
X_test[numeric_features],
X_test_categorical_encoded,
],
axis=1,
)
```
::: {.callout-note title="Remarque"}
`X_train_encoded` contient :
- les variables numériques non standardisées ;
- les variables catégorielles issues du one-hot encoding.
Cela facilitera l’interprétation des seuils utilisés par l’arbre.
:::
## Modèle de base
```{python}
from sklearn.tree import DecisionTreeClassifier
tree_model_base = DecisionTreeClassifier(
criterion="gini",
random_state=RANDOM_STATE,
)
```
Un arbre sans contrainte peut apprendre des règles très spécifiques au jeu
d’apprentissage. La validation croisée servira donc à contrôler sa
complexité.
## Recherche des hyperparamètres
Les principaux hyperparamètres étudiés sont :
- `max_depth` : profondeur maximale de l’arbre ;
- `min_samples_split` : nombre minimal d’observations pour diviser un nœud ;
- `min_samples_leaf` : nombre minimal d’observations dans une feuille ;
- `max_features` : nombre de variables examinées à chaque division ;
- `ccp_alpha` : intensité de l’élagage par coût-complexité.
```{python}
tree_parameter_grid = {
"criterion": [
"gini",
"entropy",
"log_loss",
],
"max_depth": [
2,
3,
4,
5,
6,
8,
None,
],
"min_samples_split": [
2,
5,
10,
20,
],
"min_samples_leaf": [
1,
2,
5,
10,
20,
],
"ccp_alpha": [
0.0,
0.001,
0.005,
0.01,
0.02,
],
}
```
```{python}
tree_grid_search = GridSearchCV(
estimator=tree_model_base,
param_grid=tree_parameter_grid,
scoring={
"roc_auc": "roc_auc",
"accuracy": "accuracy",
"balanced_accuracy": "balanced_accuracy",
"precision": "precision",
"recall": "recall",
"f1": "f1",
},
refit="roc_auc",
cv=cv,
n_jobs=-1,
return_train_score=True,
)
tree_grid_search.fit(
X_train_encoded,
y_train,
)
```
`GridSearchCV` évalue chaque combinaison par validation croisée, puis
réentraîne automatiquement la meilleure configuration sur tout le jeu
d’apprentissage lorsque `refit` est activé. :contentReference[oaicite:1]{index=1}
## Meilleurs hyperparamètres
```{python}
best_tree_parameters = tree_grid_search.best_params_
best_tree_cv_auc = tree_grid_search.best_score_
tree_model = tree_grid_search.best_estimator_
print("Meilleurs hyperparamètres :")
display(best_tree_parameters)
print(
"ROC-AUC moyenne en validation croisée : "
f"{best_tree_cv_auc:.3f}"
)
print("Profondeur finale :", tree_model.get_depth())
print("Nombre de feuilles :", tree_model.get_n_leaves())
```
::: {.callout-note title="Interprétation"}
La validation croisée retient un arbre utilisant le critère d’impureté de
Gini. À chaque nœud, le modèle choisit donc la séparation qui réduit le plus
l’hétérogénéité des classes.
La profondeur optimale est égale à 5. L’arbre reste ainsi d’une complexité
modérée : il peut représenter plusieurs interactions et relations non
linéaires, tout en évitant de construire des règles excessivement profondes
et spécifiques au jeu d’apprentissage.
La valeur `min_samples_leaf = 10` impose qu’une feuille contienne au moins
10 observations. Cette contrainte limite les décisions fondées sur de très
petits groupes et contribue à améliorer la stabilité du modèle.
La valeur `min_samples_split = 2` signifie qu’un nœud peut être divisé dès
qu’il contient au moins deux observations. Dans ce modèle, cette valeur est
cependant encadrée par la contrainte plus forte imposée sur la taille minimale
des feuilles.
La valeur optimale `ccp_alpha = 0` indique que la validation croisée ne
retient pas d’élagage supplémentaire par coût-complexité. La complexité de
l’arbre est déjà suffisamment contrôlée par la profondeur maximale et la
taille minimale des feuilles.
La ROC-AUC moyenne obtenue en validation croisée est de 0,908. Cette valeur
indique une bonne capacité de discrimination, mais elle reste inférieure aux
ROC-AUC moyennes proches de 0,925 obtenues avec les régressions logistiques
régularisées.
L’arbre final possède une profondeur de 5 et 21 feuilles. Il est donc assez
riche pour identifier plusieurs profils de patients, mais demeure encore
visualisable et interprétable.
Globalement, la validation croisée favorise un arbre de complexité
intermédiaire. Un arbre plus simple risquerait de sous-ajuster les données,
tandis qu’un arbre plus profond pourrait apprendre des règles trop spécifiques
et moins bien se généraliser.
:::
## Résultats de validation croisée
```{python}
tree_cv_results = pd.DataFrame(
tree_grid_search.cv_results_
)
tree_cv_summary = (
tree_cv_results[
[
"param_criterion",
"param_max_depth",
"param_min_samples_split",
"param_min_samples_leaf",
"param_ccp_alpha",
"mean_train_roc_auc",
"mean_test_roc_auc",
"std_test_roc_auc",
"mean_test_accuracy",
"mean_test_balanced_accuracy",
"mean_test_precision",
"mean_test_recall",
"mean_test_f1",
"rank_test_roc_auc",
]
]
.sort_values("rank_test_roc_auc")
.reset_index(drop=True)
)
display(tree_cv_summary.head(20).round(4))
```
## Effet de la profondeur
Pour isoler visuellement l’effet de la profondeur, nous entraînons des arbres
avec les autres paramètres fixés à ceux du meilleur modèle.
```{python}
tree_depth_rows = []
depth_values = [
1,
2,
3,
4,
5,
6,
8,
10,
12,
None,
]
for depth in depth_values:
current_tree = DecisionTreeClassifier(
criterion=best_tree_parameters[
"criterion"
],
max_depth=depth,
min_samples_split=best_tree_parameters[
"min_samples_split"
],
min_samples_leaf=best_tree_parameters[
"min_samples_leaf"
],
ccp_alpha=best_tree_parameters[
"ccp_alpha"
],
random_state=RANDOM_STATE,
)
current_scores = cross_validate(
current_tree,
X_train_encoded,
y_train,
cv=cv,
scoring="roc_auc",
return_train_score=True,
n_jobs=-1,
)
tree_depth_rows.append(
{
"profondeur": (
"Aucune limite"
if depth is None
else str(depth)
),
"profondeur_numerique": (
14 if depth is None else depth
),
"roc_auc_train":
current_scores[
"train_score"
].mean(),
"roc_auc_validation":
current_scores[
"test_score"
].mean(),
"ecart_type_validation":
current_scores[
"test_score"
].std(),
}
)
tree_depth_results = pd.DataFrame(
tree_depth_rows
)
display(tree_depth_results.round(4))
```
```{python}
#| label: fig-tree-depth
#| fig-cap: "ROC-AUC selon la profondeur de l’arbre"
plt.figure(figsize=(8, 5))
plt.plot(
tree_depth_results[
"profondeur_numerique"
],
tree_depth_results[
"roc_auc_train"
],
marker="o",
label="Apprentissage",
)
plt.plot(
tree_depth_results[
"profondeur_numerique"
],
tree_depth_results[
"roc_auc_validation"
],
marker="o",
label="Validation",
)
plt.xlabel("Profondeur maximale")
plt.ylabel("ROC-AUC moyenne")
plt.title(
"Influence de la profondeur de l’arbre"
)
plt.legend()
plt.tight_layout()
plt.show()
```
::: {.callout-note title="Interprétation de la complexité"}
Pour les profondeurs faibles, comprises entre 1 et 3, les ROC-AUC
d’apprentissage et de validation restent relativement proches, mais leurs
valeurs sont encore limitées. Cela indique que l’arbre est trop simple pour
capturer toute la structure des données : il présente alors un risque de
sous-ajustement.
La ROC-AUC de validation augmente nettement jusqu’à une profondeur de 4 à 5.
Elle atteint son maximum autour de la profondeur 5, avec une valeur proche de
0,908. Cette profondeur représente donc le meilleur compromis observé entre
capacité d’apprentissage et généralisation.
À partir d’une profondeur de 6, la ROC-AUC d’apprentissage continue
d’augmenter et se rapproche progressivement de 0,96. En revanche, la
ROC-AUC de validation diminue légèrement puis se stabilise autour de 0,90.
L’écart entre les deux courbes devient ainsi plus important lorsque la
profondeur augmente. L’arbre apprend davantage les particularités du jeu
d’apprentissage, sans améliorer ses performances sur les données de
validation. Ce comportement traduit un début de surajustement.
La profondeur 5 retenue par validation croisée est donc cohérente avec le
graphique. Elle se situe juste avant que l’écart entre apprentissage et
validation ne devienne plus marqué.
En conclusion :
- les profondeurs 1 à 3 conduisent à un modèle trop simple ;
- les profondeurs 4 à 5 offrent le meilleur compromis ;
- les profondeurs supérieures augmentent surtout la performance
d’apprentissage ;
- une profondeur élevée n’apporte pas de gain de généralisation et accroît le
risque de surajustement.
:::
## Visualisation de l’arbre optimal
```{python}
from sklearn.tree import plot_tree
plt.figure(figsize=(22, 12))
plot_tree(
tree_model,
feature_names=X_train_encoded.columns,
class_names=[
"Absence",
"Présence",
],
filled=True,
rounded=True,
proportion=True,
precision=3,
max_depth=4,
fontsize=8,
)
plt.title(
"Arbre de décision optimal "
"— quatre premiers niveaux"
)
plt.tight_layout()
plt.show()
```
`max_depth=4` dans `plot_tree` limite seulement l’affichage. Il ne modifie pas
le modèle entraîné.
### Lecture d’un nœud
Chaque nœud affiche notamment :
- la règle de séparation ;
- l’impureté ;
- la proportion d’observations ;
- la distribution des classes ;
- la classe majoritaire prédite.
::: {.callout-note title="Interprétation de l’arbre"}
La première variable utilisée à la racine est `ST_Slope_Up`. Cela signifie que,
parmi toutes les variables disponibles, cette modalité produit la meilleure
séparation initiale entre les patients avec et sans maladie cardiaque.
La règle de la racine est :
$$
\texttt{ST\_Slope\_Up} \leq 0{,}5.
$$
Comme cette variable est binaire, la branche gauche correspond aux patients
pour lesquels `ST_Slope_Up = 0`, tandis que la branche droite correspond aux
patients pour lesquels `ST_Slope_Up = 1`.
Cette première séparation est cohérente avec la régression logistique :
`ST_Slope_Up` possédait un coefficient fortement négatif. Sa présence était
donc associée à une probabilité plus faible de maladie cardiaque.
Parmi les patients ne présentant pas la modalité `ST_Slope_Up`, la deuxième
variable importante est `MaxHR`, avec un seuil d’environ 142,5. Une fréquence
cardiaque maximale faible est davantage associée à la classe « présence de
maladie », tandis qu’une valeur plus élevée conduit à des profils plus
hétérogènes.
Dans la branche caractérisée par `MaxHR <= 142.5`, le sexe intervient ensuite.
Les patients masculins sont fréquemment orientés vers des feuilles prédisant
la présence de maladie. Ce résultat est cohérent avec le coefficient positif
de `Sex_M` observé dans les modèles logistiques.
Pour les patientes, l’absence d’angine provoquée par l’exercice conduit vers
une feuille prédisant plutôt l’absence de maladie. En revanche, lorsque
`ExerciseAngina_Y = 1`, l’arbre prédit la présence de maladie, quelle que soit
ensuite la valeur d’`Oldpeak` dans les branches affichées.
Chez les hommes, `ChestPainType_NAP` constitue une autre variable de
séparation importante. Les règles sont ensuite précisées par des seuils de
`Cholesterol` et d’`Oldpeak`. Même si certaines divisions supplémentaires ne
changent pas la classe finale, elles modifient la proportion estimée de
patients malades dans les feuilles.
Dans l’autre branche principale, correspondant à `ST_Slope_Up = 1`,
`Cholesterol` est utilisé très tôt dans l’arbre, avec un seuil proche de
42,5. Ce seuil très faible doit être interprété avec prudence. Dans ce jeu de
données, certaines valeurs de cholestérol égales ou proches de zéro peuvent
représenter des données manquantes codées numériquement plutôt qu’un niveau
physiologique réel.
Les variables `ExerciseAngina_Y`, `Age`, `Oldpeak`, `MaxHR`,
`ChestPainType_NAP` et `FastingBS_1` apparaissent également dans les premiers
niveaux. L’arbre met donc en évidence des interactions : l’effet d’une
variable dépend du chemin déjà suivi et du profil défini par les variables
précédentes.
Les règles les plus importantes se concentrent dans les trois premiers
niveaux de l’arbre. Les divisions plus profondes servent surtout à affiner les
prédictions au sein de sous-groupes plus petits.
Globalement, la structure de l’arbre est cohérente avec les modèles
logistiques et l’analyse exploratoire. Les mêmes variables principales
réapparaissent, notamment `ST_Slope_Up`, `MaxHR`, `Sex_M`,
`ExerciseAngina_Y`, `Oldpeak` et les modalités de `ChestPainType`.
La différence essentielle est que la régression logistique estime un effet
global pour chaque variable, tandis que l’arbre représente des règles
conditionnelles et des interactions. Il montre par exemple que l’importance
de `MaxHR` ou de `ExerciseAngina_Y` dépend d’abord de la valeur de
`ST_Slope_Up`.
:::
## Règles textuelles de l’arbre
```{python}
from sklearn.tree import export_text
tree_rules = export_text(
tree_model,
feature_names=list(
X_train_encoded.columns
),
max_depth=5,
)
print(tree_rules)
```
Cette représentation permet de lire précisément les premières règles de
classification.
## Importance des variables
L’importance d’une variable mesure la réduction totale pondérée de l’impureté
obtenue grâce aux divisions utilisant cette variable.
```{python}
tree_importance = pd.DataFrame(
{
"variable":
X_train_encoded.columns,
"importance":
tree_model.feature_importances_,
}
)
tree_importance = (
tree_importance
.sort_values(
"importance",
ascending=False,
)
.reset_index(drop=True)
)
display(tree_importance.round(4))
```
```{python}
#| label: fig-tree-importance
#| fig-cap: "Importance des variables dans l’arbre de décision"
tree_importance_plot = (
tree_importance
.sort_values("importance")
)
tree_importance_plot.plot(
x="variable",
y="importance",
kind="barh",
figsize=(9, 7),
legend=False,
)
plt.xlabel("Importance fondée sur l’impureté")
plt.ylabel("Variable")
plt.title(
"Importance des variables — Arbre de décision"
)
plt.tight_layout()
plt.show()
```
::: {.callout-warning title="Limite de l’importance fondée sur l’impureté"}
Une importance élevée ne démontre pas une relation causale. Cette mesure peut
aussi favoriser certaines variables possédant de nombreux seuils possibles.
Elle sera complétée plus tard par l’importance par permutation.
:::
## Importance par permutation
L’importance par permutation mesure la diminution de performance obtenue
lorsque les valeurs d’une variable sont mélangées aléatoirement.
```{python}
from sklearn.inspection import permutation_importance
tree_permutation = permutation_importance(
tree_model,
X_test_encoded,
y_test,
scoring="roc_auc",
n_repeats=30,
random_state=RANDOM_STATE,
n_jobs=-1,
)
tree_permutation_importance = pd.DataFrame(
{
"variable":
X_test_encoded.columns,
"importance_moyenne":
tree_permutation.importances_mean,
"ecart_type":
tree_permutation.importances_std,
}
)
tree_permutation_importance = (
tree_permutation_importance
.sort_values(
"importance_moyenne",
ascending=False,
)
)
display(
tree_permutation_importance.round(4)
)
```
```{python}
tree_permutation_plot = (
tree_permutation_importance
.sort_values("importance_moyenne")
)
plt.figure(figsize=(9, 7))
plt.barh(
tree_permutation_plot["variable"],
tree_permutation_plot[
"importance_moyenne"
],
xerr=tree_permutation_plot[
"ecart_type"
],
)
plt.axvline(0, linewidth=1)
plt.xlabel(
"Diminution moyenne de la ROC-AUC"
)
plt.ylabel("Variable")
plt.title(
"Importance par permutation — Arbre"
)
plt.tight_layout()
plt.show()
```
::: {.callout-note title="Interprétation des importances"}
Les deux méthodes d’importance identifient `ST_Slope_Up` comme la variable
la plus influente de l’arbre.
Selon l’importance fondée sur la réduction de l’impureté,
`ST_Slope_Up` représente environ 66,8 % de l’importance totale. Cette
domination s’explique notamment par sa position à la racine de l’arbre : elle
intervient dès la première séparation et affecte donc un grand nombre
d’observations.
Les variables suivantes les plus utilisées par l’arbre sont :
- `Cholesterol`, avec une importance d’environ 11,7 % ;
- `MaxHR`, avec environ 4,6 % ;
- `ExerciseAngina_Y`, avec environ 3,8 % ;
- `Sex_M` et `ChestPainType_NAP`, avec environ 3 % chacune ;
- `Oldpeak` et `FastingBS_1`, avec des contributions plus faibles.
L’importance par permutation confirme globalement cette hiérarchie.
La permutation de `ST_Slope_Up` entraîne la plus forte diminution moyenne de
la ROC-AUC, soit environ 0,143. Cette variable apporte donc une information
prédictive majeure sur le jeu de test.
La permutation de `Cholesterol` diminue ensuite la ROC-AUC d’environ 0,052.
`FastingBS_1`, `MaxHR`, `ExerciseAngina_Y` et `Sex_M` ont également une
importance positive, mais plus modérée.
Les deux méthodes sont donc cohérentes sur les variables principales :
- `ST_Slope_Up` est nettement dominante ;
- `Cholesterol` occupe la deuxième position ;
- `MaxHR`, `ExerciseAngina_Y`, `Sex_M` et `FastingBS_1` complètent le groupe
des variables les plus utiles.
Certaines différences apparaissent néanmoins. Par exemple,
`ChestPainType_NAP` possède une importance fondée sur l’impureté d’environ
0,030, mais son importance par permutation est très faible et son écart-type
est supérieur à sa moyenne. Son effet prédictif sur le jeu de test paraît donc
peu stable ou redondant avec d’autres variables.
De même, `RestingBP`, `Age` et `Oldpeak` ont des importances par permutation
faibles, parfois proches de leur variabilité estimée. Leur contribution
supplémentaire au pouvoir discriminant de l’arbre semble donc limitée.
Les variables suivantes ont une importance nulle dans les deux analyses :
- `ChestPainType_ATA` ;
- `ChestPainType_TA` ;
- `RestingECG_Normal` ;
- `RestingECG_ST` ;
- `ST_Slope_Flat`.
Elles ne sont pas utilisées dans les divisions de l’arbre optimal. Leur
importance nulle ne signifie toutefois pas qu’elles sont totalement
indépendantes de la maladie, mais qu’elles n’apportent pas d’amélioration
supplémentaire dans la structure retenue par cet arbre.
La comparaison avec les régressions logistiques montre une cohérence partielle.
`ST_Slope_Up`, `Sex_M`, `FastingBS_1`, `ExerciseAngina_Y`, `MaxHR`,
`Oldpeak` et certaines modalités de `ChestPainType` figuraient déjà parmi les
variables importantes des modèles logistiques.
En revanche, l’arbre accorde une place beaucoup plus importante à
`Cholesterol`, notamment en raison de son utilisation dans plusieurs règles de
séparation. Cette importance doit être interprétée avec prudence, car certaines
valeurs très faibles ou nulles peuvent correspondre à des données manquantes
codées numériquement.
En conclusion, les deux méthodes confirment que l’arbre repose principalement
sur `ST_Slope_Up`, puis sur `Cholesterol` et un groupe secondaire composé de
`MaxHR`, `FastingBS_1`, `ExerciseAngina_Y` et `Sex_M`. La concentration de
l’importance sur une seule variable rend le modèle facile à interpréter, mais
peut aussi le rendre plus sensible aux variations de cette variable.
:::
## Prédictions sur le jeu de test
```{python}
y_pred_tree = tree_model.predict(
X_test_encoded
)
y_proba_tree = tree_model.predict_proba(
X_test_encoded
)[:, 1]
```
Les classes utilisent le seuil de décision par défaut de $0{,}5$.
## Indicateurs du modèle
```{python}
tn_tree, fp_tree, fn_tree, tp_tree = (
confusion_matrix(
y_test,
y_pred_tree,
).ravel()
)
sensitivity_tree = (
tp_tree
/ (tp_tree + fn_tree)
)
specificity_tree = (
tn_tree
/ (tn_tree + fp_tree)
)
tree_metrics = pd.Series(
{
"Accuracy":
accuracy_score(
y_test,
y_pred_tree,
),
"Balanced accuracy":
balanced_accuracy_score(
y_test,
y_pred_tree,
),
"Précision":
precision_score(
y_test,
y_pred_tree,
),
"Sensibilité / Recall":
sensitivity_tree,
"Spécificité":
specificity_tree,
"F1-score":
f1_score(
y_test,
y_pred_tree,
),
"ROC-AUC":
roc_auc_score(
y_test,
y_proba_tree,
),
"Average Precision":
average_precision_score(
y_test,
y_proba_tree,
),
},
name="Arbre de décision",
)
display(
tree_metrics
.to_frame("score")
.round(3)
)
```
## Rapport de classification
```{python}
print(
classification_report(
y_test,
y_pred_tree,
target_names=[
"Absence de maladie",
"Présence de maladie",
],
digits=3,
)
)
```
## Matrice de confusion
```{python}
#| label: fig-confusion-tree
#| fig-cap: "Matrice de confusion de l’arbre de décision"
ConfusionMatrixDisplay.from_predictions(
y_test,
y_pred_tree,
display_labels=[
"Absence",
"Présence",
],
)
plt.title(
"Matrice de confusion — Arbre de décision"
)
plt.tight_layout()
plt.show()
```
::: {.callout-important title="Interprétation de la matrice de confusion"}
- vrais positifs : `{python} tp_tree` ;
- vrais négatifs : `{python} tn_tree` ;
- faux positifs : `{python} fp_tree` ;
- faux négatifs : `{python} fn_tree`.
Le modèle identifie correctement 87 des 102 patients présentant réellement
une maladie cardiaque. Sa sensibilité est donc de 85,3 %.
En revanche, 15 patients malades sont classés à tort comme non malades. Ce
nombre de faux négatifs est nettement supérieur à celui obtenu avec les
modèles logistiques, qui n’en produisaient que 7.
Parmi les 82 patients sans maladie cardiaque, 66 sont correctement classés et
16 sont considérés à tort comme positifs. La spécificité atteint ainsi 80,5 %.
Les performances globales de l’arbre sont :
- accuracy : 83,2 % ;
- balanced accuracy : 82,9 % ;
- précision : 84,5 % ;
- sensibilité : 85,3 % ;
- spécificité : 80,5 % ;
- F1-score : 0,849 ;
- ROC-AUC : 0,876 ;
- Average Precision : 0,853.
Ces résultats sont inférieurs à ceux des modèles logistiques. Par rapport à
la régression logistique, l’arbre produit 8 faux négatifs supplémentaires et
2 faux positifs supplémentaires.
La baisse de sensibilité est particulièrement importante dans une logique de
dépistage, car elle signifie qu’une proportion plus élevée de patients
malades ne serait pas détectée par le modèle.
L’arbre reste relativement équilibré entre les deux classes, comme l’indique
la balanced accuracy de 82,9 %. Toutefois, sa capacité de discrimination,
mesurée par la ROC-AUC, est sensiblement plus faible que celle des modèles
logistiques, dont la ROC-AUC était proche de 0,929.
En conclusion, l’arbre de décision offre une représentation interprétable sous
forme de règles, mais cette simplicité s’accompagne ici d’une dégradation
claire des performances prédictives. Il ne constitue donc pas, à ce stade, une
meilleure alternative aux modèles logistiques.
:::
## Courbe ROC
```{python}
#| label: fig-roc-tree
#| fig-cap: "Courbe ROC de l’arbre de décision"
tree_roc_auc = roc_auc_score(
y_test,
y_proba_tree,
)
RocCurveDisplay.from_predictions(
y_test,
y_proba_tree,
name=(
"Arbre de décision — "
f"AUC = {tree_roc_auc:.3f}"
),
)
plt.plot(
[0, 1],
[0, 1],
linestyle="--",
label="Modèle aléatoire",
)
plt.title(
"Courbe ROC — Arbre de décision"
)
plt.xlabel("Taux de faux positifs")
plt.ylabel("Taux de vrais positifs")
plt.legend()
plt.tight_layout()
plt.show()
```
La courbe ROC d’un arbre peut paraître moins lisse que celle d’une régression
logistique, car l’arbre produit souvent un nombre limité de probabilités
distinctes, correspondant aux proportions observées dans ses feuilles.
## Courbe précision-rappel
```{python}
#| label: fig-pr-tree
#| fig-cap: "Courbe précision-rappel de l’arbre de décision"
tree_average_precision = (
average_precision_score(
y_test,
y_proba_tree,
)
)
PrecisionRecallDisplay.from_predictions(
y_test,
y_proba_tree,
name=(
"Arbre de décision — "
f"AP = {tree_average_precision:.3f}"
),
)
plt.title(
"Courbe précision-rappel "
"— Arbre de décision"
)
plt.xlabel("Rappel")
plt.ylabel("Précision")
plt.legend()
plt.tight_layout()
plt.show()
```
## Comparaison avec les modèles logistiques
```{python}
model_comparison_with_tree = pd.concat(
[
logistic_metrics.rename(
"Non pénalisée"
),
ridge_metrics.rename(
"Ridge"
),
lasso_metrics.rename(
"Lasso"
),
elastic_metrics.rename(
"Elastic Net"
),
tree_metrics.rename(
"Arbre de décision"
),
],
axis=1,
)
model_comparison_with_tree[
"Différence arbre vs logistique"
] = (
model_comparison_with_tree[
"Arbre de décision"
]
-
model_comparison_with_tree[
"Non pénalisée"
]
)
display(
model_comparison_with_tree.round(3)
)
```
::: {.callout-important title="Conclusion de l’arbre de décision"}
L'arbre est moins performant que nos modeleslogistiques.
:::
# Forêt aléatoire
## Principe du modèle
Une forêt aléatoire combine plusieurs arbres de décision.
Chaque arbre est entraîné :
1. sur un échantillon bootstrap du jeu d’apprentissage ;
2. en utilisant seulement une partie aléatoire des variables à chaque
séparation.
Pour une classification binaire, la prédiction finale correspond au vote
majoritaire des arbres. La probabilité prédite correspond à la moyenne des
probabilités produites par les différents arbres.
Cette méthode vise à réduire la variance d’un arbre individuel tout en
conservant sa capacité à modéliser des relations non linéaires et des
interactions entre variables.
## Données utilisées
Comme les arbres de décision, les forêts aléatoires ne nécessitent pas de
standardisation. Nous utilisons donc les données encodées dans leurs unités
originales.
```{python}
print(
"Dimensions du jeu d’apprentissage :",
X_train_encoded.shape,
)
print(
"Dimensions du jeu de test :",
X_test_encoded.shape,
)
```
## Importation du modèle
```{python}
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import RandomizedSearchCV
```
## Modèle de base
```{python}
random_forest_base = RandomForestClassifier(
random_state=RANDOM_STATE,
n_jobs=-1,
)
```
## Recherche des hyperparamètres
Les principaux hyperparamètres étudiés sont :
- `n_estimators` : nombre d’arbres ;
- `max_depth` : profondeur maximale de chaque arbre ;
- `min_samples_split` : nombre minimal d’observations nécessaire pour
diviser un nœud ;
- `min_samples_leaf` : nombre minimal d’observations dans une feuille ;
- `max_features` : nombre de variables examinées à chaque séparation ;
- `bootstrap` : utilisation ou non d’échantillons bootstrap ;
- `class_weight` : pondération éventuelle des classes.
Une recherche aléatoire est utilisée afin d’explorer un nombre important de
combinaisons sans tester exhaustivement toutes les possibilités.
```{python}
random_forest_parameter_grid = {
"n_estimators": [
200,
300,
500,
700,
1000,
],
"criterion": [
"gini",
"entropy",
"log_loss",
],
"max_depth": [
3,
4,
5,
6,
8,
10,
12,
None,
],
"min_samples_split": [
2,
5,
10,
20,
],
"min_samples_leaf": [
1,
2,
5,
10,
20,
],
"max_features": [
"sqrt",
"log2",
0.5,
None,
],
"bootstrap": [
True,
],
"class_weight": [
None,
"balanced",
"balanced_subsample",
],
}
```
```{python}
random_forest_search = RandomizedSearchCV(
estimator=random_forest_base,
param_distributions=(
random_forest_parameter_grid
),
n_iter=100,
scoring={
"roc_auc": "roc_auc",
"accuracy": "accuracy",
"balanced_accuracy":
"balanced_accuracy",
"precision": "precision",
"recall": "recall",
"f1": "f1",
},
refit="roc_auc",
cv=cv,
random_state=RANDOM_STATE,
n_jobs=-1,
return_train_score=True,
verbose=1,
)
random_forest_search.fit(
X_train_encoded,
y_train,
)
```
`RandomizedSearchCV` teste un nombre défini de combinaisons tirées dans
l’espace des hyperparamètres, puis réentraîne la meilleure configuration
lorsque `refit` est activé. :contentReference[oaicite:1]{index=1}
## Meilleurs hyperparamètres
```{python}
best_random_forest_parameters = (
random_forest_search.best_params_
)
best_random_forest_cv_auc = (
random_forest_search.best_score_
)
random_forest_model = (
random_forest_search.best_estimator_
)
print("Meilleurs hyperparamètres :")
display(
best_random_forest_parameters
)
print(
"ROC-AUC moyenne en validation croisée : "
f"{best_random_forest_cv_auc:.3f}"
)
```
::: {.callout-note title="Interprétation"}
La meilleure configuration de la forêt aléatoire contient **700 arbres**.
Ce nombre relativement élevé permet de stabiliser les prédictions par
agrégation. Au-delà d’un certain nombre d’arbres, le gain prédictif devient
généralement faible, mais la variance des résultats peut encore légèrement
diminuer.
La profondeur maximale retenue est de **10**. Les arbres individuels sont donc
plus complexes que l’arbre de décision unique précédemment sélectionné, dont
la profondeur était limitée à 5. Cette complexité est toutefois compensée par
l’agrégation de nombreux arbres et par la sélection aléatoire des variables.
La valeur `min_samples_leaf = 2` autorise des feuilles relativement petites.
En parallèle, `min_samples_split = 10` impose qu’un nœud contienne au moins
10 observations avant de pouvoir être divisé. Cette combinaison permet aux
arbres de représenter des structures assez fines tout en limitant certaines
divisions fondées sur de très petits groupes.
La valeur `max_features = "sqrt"` signifie qu’à chaque séparation, chaque
arbre n’examine qu’environ la racine carrée du nombre total de variables.
Avec 15 variables explicatives, cela correspond à environ :
$$
\sqrt{15} \approx 3{,}9,
$$
soit approximativement 4 variables candidates à chaque nœud. Cette
randomisation réduit la corrélation entre les arbres et améliore l’intérêt de
leur agrégation.
Le critère retenu est `log_loss`. Les séparations sont donc choisies afin
d’améliorer la qualité probabiliste des prédictions, plutôt qu’en utilisant
directement l’impureté de Gini.
La forêt utilise des échantillons bootstrap, puisque `bootstrap = True`.
Chaque arbre est entraîné sur un échantillon obtenu par tirage avec remise à
partir du jeu d’apprentissage.
Aucune pondération particulière des classes n’est sélectionnée :
```text
class_weight = None
```
La validation croisée n’a donc pas jugé nécessaire de compenser artificiellement
le léger déséquilibre entre les classes. Cela reste cohérent avec la répartition
relativement équilibrée du jeu de données.
La ROC-AUC moyenne obtenue en validation croisée est de :
$$
0{,}9325.
$$
Cette performance est nettement supérieure à celle de l’arbre de décision
unique, qui obtenait environ $0{,}908$. Elle dépasse également légèrement les
modèles logistiques, dont la ROC-AUC moyenne de validation était proche de
$0{,}925$. Cet avantage reste toutefois modéré et doit être confirmé sur le jeu
de test.
:::
## Résultats de validation croisée
```{python}
random_forest_cv_results = pd.DataFrame(
random_forest_search.cv_results_
)
random_forest_cv_summary = (
random_forest_cv_results[
[
"param_n_estimators",
"param_criterion",
"param_max_depth",
"param_min_samples_split",
"param_min_samples_leaf",
"param_max_features",
"param_class_weight",
"mean_train_roc_auc",
"mean_test_roc_auc",
"std_test_roc_auc",
"mean_test_accuracy",
"mean_test_balanced_accuracy",
"mean_test_precision",
"mean_test_recall",
"mean_test_f1",
"rank_test_roc_auc",
]
]
.sort_values(
"rank_test_roc_auc"
)
.reset_index(drop=True)
)
display(
random_forest_cv_summary
.head(20)
.round(4)
)
```
::: {.callout-note title="Interprétation de la validation croisée"}
La meilleure configuration atteint une ROC-AUC moyenne d’apprentissage de
$0{,}9855$ et une ROC-AUC moyenne de validation de $0{,}9325$.
L’écart entre les deux scores est donc d’environ :
$$
0{,}9855 - 0{,}9325 = 0{,}053.
$$
Cet écart montre que la forêt s’ajuste très fortement aux données
d’apprentissage. Il existe donc un certain niveau de surajustement, mais celui-ci
reste partiellement contrôlé puisque la ROC-AUC de validation demeure élevée.
L’écart-type de la ROC-AUC de validation est égal à $0{,}023$. Les
performances varient donc légèrement selon les plis, mais restent globalement
stables. La dispersion est même plus faible que celle observée avec plusieurs
modèles logistiques, dont l’écart-type était proche de $0{,}036$.
Les meilleures configurations sont très proches les unes des autres. Les deux
premières obtiennent respectivement :
- $0{,}9325$ avec le critère `log_loss` ;
- $0{,}9323$ avec le critère `gini`.
L’écart de $0{,}0002$ est négligeable. Le rang exact entre ces deux
configurations ne doit donc pas être surinterprété.
Plus généralement, les vingt meilleures combinaisons présentent des ROC-AUC
moyennes comprises approximativement entre $0{,}9288$ et $0{,}9325$.
La recherche met ainsi en évidence un plateau de bonnes performances plutôt
qu’une configuration nettement supérieure à toutes les autres.
Plusieurs choix d’hyperparamètres produisent des résultats similaires :
- entre 300 et 1 000 arbres ;
- des profondeurs comprises entre 6 et 12, voire sans limite ;
- les critères `gini`, `entropy` et `log_loss` ;
- avec ou sans pondération des classes ;
- avec `sqrt` ou `log2` pour `max_features`.
Cela montre que la forêt est relativement robuste au choix précis des
hyperparamètres dans cette zone.
Les configurations les plus complexes obtiennent parfois des ROC-AUC
d’apprentissage proches de $0{,}995$, sans amélioration correspondante de la
validation. Par exemple, une configuration atteint $0{,}9957$ en
apprentissage mais seulement $0{,}9288$ en validation. Cela confirme que
l’augmentation de la complexité peut renforcer le surajustement sans améliorer
la généralisation.
La meilleure configuration n’est d’ailleurs pas celle qui maximise l’accuracy
ou le F1-score moyen. Elle est sélectionnée parce que `GridSearchCV` ou
`RandomizedSearchCV` utilise la ROC-AUC comme métrique de référence. Certaines
configurations légèrement moins bien classées en ROC-AUC présentent une
accuracy ou un F1-score un peu supérieurs.
En conclusion, la forêt aléatoire offre une ROC-AUC de validation élevée et
relativement stable. Elle surajuste davantage que les modèles logistiques,
mais elle améliore leur ROC-AUC moyenne de validation. La présence d’un plateau
de performances suggère que le modèle ne dépend pas fortement d’une combinaison
unique d’hyperparamètres.
:::
## Score Out-of-Bag
Lorsque `bootstrap=True`, les observations non sélectionnées pour entraîner un
arbre peuvent être utilisées pour évaluer cet arbre. Elles sont appelées
observations *out-of-bag*.
Nous réentraînons la meilleure configuration avec `oob_score=True`.
```{python}
random_forest_oob = RandomForestClassifier(
**best_random_forest_parameters,
oob_score=True,
random_state=RANDOM_STATE,
n_jobs=-1,
)
random_forest_oob.fit(
X_train_encoded,
y_train,
)
print(
"Accuracy Out-of-Bag : "
f"{random_forest_oob.oob_score_:.3f}"
)
```
Le score OOB constitue une estimation interne de la capacité de
généralisation. Il ne remplace toutefois ni la validation croisée ni le jeu
de test.
## Influence du nombre d’arbres
```{python}
forest_estimators_rows = []
estimators_values = [
10,
25,
50,
100,
200,
300,
500,
700,
1000,
]
for number_of_trees in estimators_values:
current_forest = RandomForestClassifier(
**{
**best_random_forest_parameters,
"n_estimators": number_of_trees,
},
random_state=RANDOM_STATE,
n_jobs=-1,
)
current_scores = cross_validate(
current_forest,
X_train_encoded,
y_train,
cv=cv,
scoring="roc_auc",
return_train_score=True,
n_jobs=-1,
)
forest_estimators_rows.append(
{
"n_estimators":
number_of_trees,
"roc_auc_train":
current_scores[
"train_score"
].mean(),
"roc_auc_validation":
current_scores[
"test_score"
].mean(),
"ecart_type_validation":
current_scores[
"test_score"
].std(),
}
)
forest_estimators_results = pd.DataFrame(
forest_estimators_rows
)
display(
forest_estimators_results.round(4)
)
```
```{python}
#| label: fig-random-forest-estimators
#| fig-cap: "ROC-AUC selon le nombre d’arbres"
plt.figure(figsize=(8, 5))
plt.plot(
forest_estimators_results[
"n_estimators"
],
forest_estimators_results[
"roc_auc_train"
],
marker="o",
label="Apprentissage",
)
plt.plot(
forest_estimators_results[
"n_estimators"
],
forest_estimators_results[
"roc_auc_validation"
],
marker="o",
label="Validation",
)
plt.xlabel("Nombre d’arbres")
plt.ylabel("ROC-AUC moyenne")
plt.title(
"Influence du nombre d’arbres"
)
plt.legend()
plt.tight_layout()
plt.show()
```
::: {.callout-note title="Interprétation du nombre d’arbres"}
La ROC-AUC de validation augmente rapidement lorsque le nombre d’arbres passe
de 10 à environ 50–100 arbres.
Avec seulement 10 arbres, la performance de validation est proche de 0,923.
Elle atteint ensuite environ 0,930 avec 25 à 50 arbres, puis se stabilise autour
de 0,931–0,933.
À partir d’environ 100 à 200 arbres, l’ajout de nouveaux arbres apporte donc
très peu de gain prédictif. La courbe de validation forme un plateau, ce qui
indique que les estimations de la forêt sont déjà suffisamment stabilisées.
La ROC-AUC d’apprentissage suit le même comportement. Elle augmente rapidement
au début, puis se stabilise autour de 0,985. L’écart entre apprentissage et
validation reste ensuite quasiment constant lorsque le nombre d’arbres
augmente.
L’ajout d’arbres supplémentaires ne semble donc pas accentuer le surajustement.
Il améliore surtout la stabilité de l’agrégation, au prix d’un temps de calcul
et d’une consommation mémoire plus élevés.
Le choix de 700 arbres est cohérent avec la recherche aléatoire, mais il
n’apporte probablement qu’un gain très faible par rapport à une forêt de 300
ou 500 arbres. Les performances observées à 500, 700 et 1 000 arbres sont
pratiquement identiques.
En conclusion :
- le gain principal est obtenu avec les premiers 50 à 100 arbres ;
- la ROC-AUC se stabilise clairement après environ 200 arbres ;
- 700 arbres garantissent une prédiction stable ;
- une forêt de 300 à 500 arbres pourrait constituer une alternative plus
économique avec une performance presque identique.
:::
## Importance fondée sur l’impureté
```{python}
random_forest_importance = pd.DataFrame(
{
"variable":
X_train_encoded.columns,
"importance":
random_forest_model
.feature_importances_,
}
)
random_forest_importance = (
random_forest_importance
.sort_values(
"importance",
ascending=False,
)
.reset_index(drop=True)
)
display(
random_forest_importance.round(4)
)
```
```{python}
#| label: fig-random-forest-importance
#| fig-cap: "Importance des variables dans la forêt aléatoire"
random_forest_importance_plot = (
random_forest_importance
.sort_values("importance")
)
plt.figure(figsize=(9, 7))
plt.barh(
random_forest_importance_plot[
"variable"
],
random_forest_importance_plot[
"importance"
],
)
plt.xlabel(
"Importance fondée sur l’impureté"
)
plt.ylabel("Variable")
plt.title(
"Importance des variables "
"— Forêt aléatoire"
)
plt.tight_layout()
plt.show()
```
## Importance par permutation
```{python}
random_forest_permutation = (
permutation_importance(
random_forest_model,
X_test_encoded,
y_test,
scoring="roc_auc",
n_repeats=30,
random_state=RANDOM_STATE,
n_jobs=-1,
)
)
random_forest_permutation_importance = (
pd.DataFrame(
{
"variable":
X_test_encoded.columns,
"importance_moyenne":
random_forest_permutation
.importances_mean,
"ecart_type":
random_forest_permutation
.importances_std,
}
)
.sort_values(
"importance_moyenne",
ascending=False,
)
.reset_index(drop=True)
)
display(
random_forest_permutation_importance
.round(4)
)
```
```{python}
#| label: fig-random-forest-permutation
#| fig-cap: "Importance par permutation dans la forêt aléatoire"
random_forest_permutation_plot = (
random_forest_permutation_importance
.sort_values("importance_moyenne")
)
plt.figure(figsize=(9, 7))
plt.barh(
random_forest_permutation_plot[
"variable"
],
random_forest_permutation_plot[
"importance_moyenne"
],
xerr=random_forest_permutation_plot[
"ecart_type"
],
)
plt.axvline(
0,
linewidth=1,
)
plt.xlabel(
"Diminution moyenne de la ROC-AUC"
)
plt.ylabel("Variable")
plt.title(
"Importance par permutation "
"— Forêt aléatoire"
)
plt.tight_layout()
plt.show()
```
L’importance par permutation mesure la diminution d’un score lorsque les
valeurs d’une variable sont mélangées. Elle doit être interprétée sur un
modèle dont les performances prédictives sont satisfaisantes. :contentReference[oaicite:2]{index=2}
::: {.callout-note title="Interprétation des importances"}
L’importance fondée sur la réduction de l’impureté est beaucoup plus répartie
dans la forêt aléatoire que dans l’arbre de décision unique.
Les variables les plus importantes selon cette première mesure sont :
- `ST_Slope_Up`, avec une importance de 0,1814 ;
- `ST_Slope_Flat`, avec 0,1284 ;
- `Oldpeak`, avec 0,1112 ;
- `Cholesterol`, avec 0,0987 ;
- `MaxHR`, avec 0,0965 ;
- `ExerciseAngina_Y`, avec 0,0914.
Dans l’arbre unique, `ST_Slope_Up` représentait à elle seule environ 66,8 %
de l’importance totale. Dans la forêt, son importance diminue à environ
18,1 %, car les différents arbres utilisent des variables et des seuils
différents. La prédiction dépend donc moins fortement d’une seule variable.
L’importance par permutation produit une hiérarchie différente. Les variables
dont la permutation dégrade le plus la ROC-AUC sont :
- `Cholesterol`, avec une diminution moyenne de 0,0208 ;
- `ST_Slope_Up`, avec 0,0197 ;
- `Sex_M`, avec 0,0191 ;
- `ExerciseAngina_Y`, avec 0,0143 ;
- `ChestPainType_ATA`, avec 0,0125 ;
- `FastingBS_1`, avec 0,0089.
Ces variables semblent apporter l’information prédictive la plus difficile à
remplacer par les autres variables du modèle.
Les deux méthodes sont cohérentes pour plusieurs variables importantes,
notamment `ST_Slope_Up`, `Cholesterol`, `ExerciseAngina_Y`, `Oldpeak` et
`MaxHR`. Elles confirment que ces caractéristiques participent de manière
importante aux prédictions de la forêt.
Certaines différences sont néanmoins importantes. Par exemple,
`ST_Slope_Flat` est classée deuxième selon la réduction de l’impureté, mais sa
permutation ne réduit la ROC-AUC que de 0,0047, avec un écart-type de 0,0072.
Son importance prédictive propre apparaît donc faible ou instable.
De même, `Age` possède une importance fondée sur l’impureté de 0,0636, mais
son importance par permutation est presque nulle. La forêt utilise donc
régulièrement cette variable pour construire des divisions, sans que sa
suppression individuelle détériore nettement les performances.
Ce phénomène peut s’expliquer par la redondance entre variables. Lorsque deux
variables contiennent des informations proches, la forêt peut utiliser l’une
ou l’autre selon les arbres. La permutation d’une seule variable a alors un
effet limité, car les autres peuvent partiellement la remplacer.
À l’inverse, `Sex_M` et `ChestPainType_ATA` ont une importance fondée sur
l’impureté relativement modérée, mais une importance par permutation élevée.
Même si elles sont moins souvent utilisées dans les arbres, elles fournissent
une information complémentaire utile à la discrimination.
Les variables situées en bas du classement par permutation, telles que
`RestingECG_Normal`, `ChestPainType_TA`, `Age`, `ChestPainType_NAP` et
`RestingECG_ST`, ont une contribution marginale au score obtenu sur le jeu de
test. Leurs diminutions moyennes de ROC-AUC sont proches de zéro et parfois
inférieures à leur écart-type.
La hiérarchie obtenue reste globalement cohérente avec les modèles
logistiques. Les variables `ST_Slope_Up`, `Sex_M`, `ExerciseAngina_Y`,
`FastingBS_1`, `Oldpeak`, `MaxHR` et les modalités de `ChestPainType`
présentaient déjà des coefficients importants.
La forêt accorde cependant une place plus importante à `Cholesterol` et
`RestingBP`. Cette différence peut provenir de sa capacité à exploiter des
seuils et des interactions non linéaires, alors que la régression logistique
estime principalement des effets linéaires globaux.
En conclusion, la forêt aléatoire repose sur un ensemble plus diversifié de
variables que l’arbre unique. L’importance par permutation suggère que les
variables les plus robustement utiles sont surtout `Cholesterol`,
`ST_Slope_Up`, `Sex_M`, `ExerciseAngina_Y`, `ChestPainType_ATA` et
`FastingBS_1`.
:::
## Prédictions sur le jeu de test
```{python}
y_pred_random_forest = (
random_forest_model.predict(
X_test_encoded
)
)
y_proba_random_forest = (
random_forest_model.predict_proba(
X_test_encoded
)[:, 1]
)
```
## Indicateurs du modèle
```{python}
(
tn_random_forest,
fp_random_forest,
fn_random_forest,
tp_random_forest,
) = confusion_matrix(
y_test,
y_pred_random_forest,
).ravel()
sensitivity_random_forest = (
tp_random_forest
/
(
tp_random_forest
+ fn_random_forest
)
)
specificity_random_forest = (
tn_random_forest
/
(
tn_random_forest
+ fp_random_forest
)
)
random_forest_metrics = pd.Series(
{
"Accuracy":
accuracy_score(
y_test,
y_pred_random_forest,
),
"Balanced accuracy":
balanced_accuracy_score(
y_test,
y_pred_random_forest,
),
"Précision":
precision_score(
y_test,
y_pred_random_forest,
),
"Sensibilité / Recall":
sensitivity_random_forest,
"Spécificité":
specificity_random_forest,
"F1-score":
f1_score(
y_test,
y_pred_random_forest,
),
"ROC-AUC":
roc_auc_score(
y_test,
y_proba_random_forest,
),
"Average Precision":
average_precision_score(
y_test,
y_proba_random_forest,
),
},
name="Forêt aléatoire",
)
display(
random_forest_metrics
.to_frame("score")
.round(3)
)
```
## Rapport de classification
```{python}
print(
classification_report(
y_test,
y_pred_random_forest,
target_names=[
"Absence de maladie",
"Présence de maladie",
],
digits=3,
)
)
```
## Matrice de confusion
```{python}
#| label: fig-confusion-random-forest
#| fig-cap: "Matrice de confusion de la forêt aléatoire"
ConfusionMatrixDisplay.from_predictions(
y_test,
y_pred_random_forest,
display_labels=[
"Absence",
"Présence",
],
)
plt.title(
"Matrice de confusion "
"— Forêt aléatoire"
)
plt.tight_layout()
plt.show()
```
::: {.callout-important title="Interprétation de la matrice de confusion"}
- vrais positifs : `{python} tp_random_forest` ;
- vrais négatifs : `{python} tn_random_forest` ;
- faux positifs : `{python} fp_random_forest` ;
- faux négatifs : `{python} fn_random_forest`.
La forêt identifie correctement 94 des 102 patients présentant réellement une
maladie cardiaque. Sa sensibilité atteint ainsi 92,2 %.
Elle produit 8 faux négatifs, contre 15 pour l’arbre de décision. L’agrégation
de plusieurs arbres améliore donc nettement la détection des patients malades
par rapport à l’arbre unique.
Parmi les 82 patients sans maladie, 68 sont correctement classés et 14 sont
prédits à tort comme positifs. La spécificité est donc de 82,9 %.
Les performances globales de la forêt sont :
- accuracy : 88,0 % ;
- balanced accuracy : 87,5 % ;
- précision : 87,0 % ;
- sensibilité : 92,2 % ;
- spécificité : 82,9 % ;
- F1-score : 0,895 ;
- ROC-AUC : 0,931 ;
- Average Precision : 0,930.
La forêt aléatoire améliore clairement l’arbre de décision sur l’ensemble des
indicateurs. Elle réduit notamment le nombre de faux négatifs de 15 à 8 et
augmente la ROC-AUC de 0,876 à 0,931.
Par rapport aux modèles logistiques, les résultats sont très proches. Les
modèles logistiques produisaient 7 faux négatifs, 14 faux positifs et une
sensibilité de 93,1 %. La forêt produit donc un faux négatif supplémentaire et
une sensibilité légèrement plus faible, à 92,2 %.
La ROC-AUC de la forêt, égale à 0,931, est toutefois légèrement supérieure aux
valeurs obtenues par la plupart des régressions logistiques, comprises entre
0,929 et 0,930. Cet écart reste très faible et ne suffit pas, à lui seul, à
conclure à une supériorité nette.
L’Average Precision de 0,930 est en revanche légèrement inférieure à celle des
modèles logistiques, qui atteignaient environ 0,936 à 0,937.
En conclusion, la forêt aléatoire constitue une amélioration importante par
rapport à l’arbre unique. Elle atteint des performances comparables aux modèles
logistiques et présente une très bonne capacité de discrimination. Néanmoins,
au seuil de 0,5, elle ne réduit pas le nombre de faux négatifs par rapport aux
régressions logistiques et reste moins simple à interpréter.
:::
## Courbe ROC
```{python}
#| label: fig-roc-random-forest
#| fig-cap: "Courbe ROC de la forêt aléatoire"
random_forest_roc_auc = (
roc_auc_score(
y_test,
y_proba_random_forest,
)
)
RocCurveDisplay.from_predictions(
y_test,
y_proba_random_forest,
name=(
"Forêt aléatoire — "
f"AUC = "
f"{random_forest_roc_auc:.3f}"
),
)
plt.plot(
[0, 1],
[0, 1],
linestyle="--",
label="Modèle aléatoire",
)
plt.title(
"Courbe ROC — Forêt aléatoire"
)
plt.xlabel("Taux de faux positifs")
plt.ylabel("Taux de vrais positifs")
plt.legend()
plt.tight_layout()
plt.show()
```
## Courbe précision-rappel
```{python}
#| label: fig-pr-random-forest
#| fig-cap: "Courbe précision-rappel de la forêt aléatoire"
random_forest_average_precision = (
average_precision_score(
y_test,
y_proba_random_forest,
)
)
PrecisionRecallDisplay.from_predictions(
y_test,
y_proba_random_forest,
name=(
"Forêt aléatoire — "
f"AP = "
f"{random_forest_average_precision:.3f}"
),
)
plt.title(
"Courbe précision-rappel "
"— Forêt aléatoire"
)
plt.xlabel("Rappel")
plt.ylabel("Précision")
plt.legend()
plt.tight_layout()
plt.show()
```
## Comparaison avec les modèles précédents
```{python}
model_comparison_with_random_forest = (
pd.concat(
[
logistic_metrics.rename(
"Non pénalisée"
),
ridge_metrics.rename(
"Ridge"
),
lasso_metrics.rename(
"Lasso"
),
elastic_metrics.rename(
"Elastic Net"
),
tree_metrics.rename(
"Arbre de décision"
),
random_forest_metrics.rename(
"Forêt aléatoire"
),
],
axis=1,
)
)
model_comparison_with_random_forest[
"Différence forêt vs logistique"
] = (
model_comparison_with_random_forest[
"Forêt aléatoire"
]
-
model_comparison_with_random_forest[
"Non pénalisée"
]
)
model_comparison_with_random_forest[
"Différence forêt vs arbre"
] = (
model_comparison_with_random_forest[
"Forêt aléatoire"
]
-
model_comparison_with_random_forest[
"Arbre de décision"
]
)
display(
model_comparison_with_random_forest
.round(3)
)
```
::: {.callout-important title="Conclusion de la forêt aléatoire"}
La forêt aléatoire améliore nettement les performances obtenues avec l’arbre
de décision unique.
En validation croisée, elle atteint une ROC-AUC moyenne de $0{,}9325$, contre
environ $0{,}908$ pour l’arbre. L’agrégation de plusieurs arbres permet donc
de réduire la variabilité du modèle et d’améliorer sa capacité de
généralisation.
Sur le jeu de test, la forêt obtient une ROC-AUC de $0{,}931$, contre
$0{,}876$ pour l’arbre de décision. Elle améliore également l’accuracy, la
balanced accuracy, la sensibilité et le score F1.
Le nombre de faux négatifs diminue fortement :
- arbre de décision : 15 faux négatifs ;
- forêt aléatoire : 8 faux négatifs.
La forêt détecte donc beaucoup mieux les patients malades que l’arbre unique.
Elle conserve toutefois un faux négatif de plus que les modèles logistiques,
qui en produisaient 7.
Les performances de la forêt sont globalement comparables à celles des
régressions logistiques. Sa ROC-AUC de test est légèrement supérieure, mais
l’écart reste très faible. Son Average Precision est en revanche légèrement
inférieure. Il n’est donc pas possible d’affirmer qu’elle est nettement
supérieure aux modèles logistiques.
La ROC-AUC d’apprentissage, proche de $0{,}986$, reste supérieure à la
ROC-AUC de validation de $0{,}933$. Cet écart d’environ $0{,}053$ indique
un certain surajustement. Celui-ci reste cependant maîtrisé, car les
performances de validation sont élevées, relativement stables et confirmées
sur le jeu de test.
Les importances sont beaucoup plus réparties que dans l’arbre unique.
`ST_Slope_Up` reste une variable importante, mais elle ne concentre plus à elle
seule la majorité de l’importance. La forêt exploite également
`Cholesterol`, `Oldpeak`, `MaxHR`, `ExerciseAngina_Y`, `Sex_M`,
`ChestPainType_ATA` et `FastingBS_1`.
Cette répartition montre que la forêt utilise plusieurs chemins et plusieurs
combinaisons de variables. Elle est ainsi moins dépendante d’une seule règle
de décision et plus robuste qu’un arbre isolé.
La perte d’interprétabilité par rapport à l’arbre unique est donc compensée
par un gain prédictif important. En revanche, par rapport aux régressions
logistiques, le gain de performance reste faible alors que le modèle devient
plus complexe à expliquer.
En conclusion, la forêt aléatoire constitue un meilleur compromis que l’arbre
de décision entre non-linéarité, stabilité et performance. Elle permet de
modéliser des interactions complexes tout en obtenant des résultats proches,
voire légèrement supérieurs, à ceux des modèles logistiques.
Cependant, si la priorité est l’interprétabilité et la simplicité, la
régression logistique Ridge reste très compétitive. Si la priorité est la
capacité à modéliser des relations non linéaires, la forêt aléatoire constitue
à ce stade le modèle le plus intéressant de l’étude.
:::
# Gradient Boosting
## Principe du modèle
Le Gradient Boosting construit progressivement un modèle composé de plusieurs
arbres de décision.
Contrairement à la forêt aléatoire, dans laquelle les arbres sont entraînés
indépendamment, les arbres du Gradient Boosting sont construits
séquentiellement.
Chaque nouvel arbre tente de corriger les erreurs résiduelles commises par
l’ensemble des arbres précédents.
Pour une observation $x$, le modèle construit une fonction additive :
$$
F_M(x)
=
F_0(x)
+
\sum_{m=1}^{M}
\eta h_m(x),
$$
où :
- $M$ représente le nombre d’arbres ;
- $h_m(x)$ représente le nouvel arbre ajouté à l’étape $m$ ;
- $\eta$ représente le taux d’apprentissage `learning_rate`.
Une faible valeur de `learning_rate` réduit la contribution de chaque arbre
et nécessite généralement un nombre d’arbres plus élevé.
## Données utilisées
Les arbres ne nécessitent pas de standardisation. Nous utilisons donc les
variables numériques dans leurs unités originales et les variables
catégorielles encodées.
```{python}
print(
"Dimensions du jeu d’apprentissage :",
X_train_encoded.shape,
)
print(
"Dimensions du jeu de test :",
X_test_encoded.shape,
)
```
## Importation du modèle
```{python}
from sklearn.ensemble import (
GradientBoostingClassifier,
)
from sklearn.model_selection import (
RandomizedSearchCV,
cross_validate,
)
```
## Modèle de base
```{python}
gradient_boosting_base = (
GradientBoostingClassifier(
random_state=RANDOM_STATE,
)
)
```
## Recherche des hyperparamètres
Les principaux hyperparamètres étudiés sont :
- `n_estimators` : nombre d’arbres ajoutés séquentiellement ;
- `learning_rate` : contribution de chaque arbre ;
- `max_depth` : profondeur des arbres individuels ;
- `min_samples_split` : nombre minimal d’observations pour diviser un nœud ;
- `min_samples_leaf` : nombre minimal d’observations dans une feuille ;
- `subsample` : proportion du jeu d’apprentissage utilisée pour chaque arbre ;
- `max_features` : nombre de variables examinées lors des divisions.
```{python}
gradient_boosting_parameter_grid = {
"n_estimators": [
50,
100,
150,
200,
300,
500,
],
"learning_rate": [
0.01,
0.03,
0.05,
0.1,
0.2,
],
"max_depth": [
1,
2,
3,
4,
5,
],
"min_samples_split": [
2,
5,
10,
20,
],
"min_samples_leaf": [
1,
2,
5,
10,
20,
],
"subsample": [
0.6,
0.8,
1.0,
],
"max_features": [
None,
"sqrt",
"log2",
],
}
```
Une recherche aléatoire est utilisée afin d’explorer plusieurs combinaisons
sans tester exhaustivement tout l’espace des hyperparamètres.
```{python}
gradient_boosting_search = (
RandomizedSearchCV(
estimator=gradient_boosting_base,
param_distributions=(
gradient_boosting_parameter_grid
),
n_iter=100,
scoring={
"roc_auc": "roc_auc",
"accuracy": "accuracy",
"balanced_accuracy":
"balanced_accuracy",
"precision": "precision",
"recall": "recall",
"f1": "f1",
},
refit="roc_auc",
cv=cv,
random_state=RANDOM_STATE,
n_jobs=-1,
return_train_score=True,
verbose=1,
)
)
gradient_boosting_search.fit(
X_train_encoded,
y_train,
)
```
`RandomizedSearchCV` évalue un nombre déterminé de configurations, puis
réentraîne la meilleure configuration sur l’ensemble du jeu d’apprentissage
lorsque `refit` est activé.
## Meilleurs hyperparamètres
```{python}
best_gradient_boosting_parameters = (
gradient_boosting_search.best_params_
)
best_gradient_boosting_cv_auc = (
gradient_boosting_search.best_score_
)
gradient_boosting_model = (
gradient_boosting_search.best_estimator_
)
print("Meilleurs hyperparamètres :")
display(
best_gradient_boosting_parameters
)
print(
"ROC-AUC moyenne en validation croisée : "
f"{best_gradient_boosting_cv_auc:.3f}"
)
```
::: {.callout-note title="Interprétation"}
La meilleure configuration du Gradient Boosting retient **50 arbres** avec un
taux d’apprentissage égal à `0.1`.
Cette combinaison correspond à une progression relativement rapide : chaque
arbre contribue de manière assez importante à la correction des erreurs. Le
modèle n’a donc pas besoin d’un très grand nombre d’arbres pour atteindre sa
meilleure performance en validation croisée.
Les arbres individuels ont une profondeur maximale de **5**. Ils peuvent ainsi
modéliser des interactions non linéaires relativement complexes entre les
variables, tout en restant moins profonds que des arbres totalement libres.
La valeur `min_samples_leaf = 5` impose qu’une feuille contienne au moins cinq
observations. Cette contrainte limite la création de sous-groupes trop petits
et contribue à contrôler le surajustement.
La valeur `min_samples_split = 5` signifie qu’un nœud doit contenir au moins
cinq observations avant de pouvoir être divisé. En pratique, la contrainte sur
la taille minimale des feuilles joue également un rôle important dans le
contrôle de la complexité.
Le paramètre `subsample = 0.6` indique que chaque nouvel arbre est entraîné sur
un échantillon aléatoire contenant 60 % des observations du jeu
d’apprentissage. Le modèle correspond donc à un Gradient Boosting
stochastique.
Cette sous-utilisation volontaire des observations à chaque étape introduit de
la diversité entre les arbres et peut réduire la variance du modèle. Elle joue
un rôle comparable à une forme de régularisation.
La valeur `max_features = "sqrt"` signifie qu’à chaque séparation, l’arbre
n’examine qu’environ la racine carrée du nombre total de variables. Avec 15
variables explicatives, cela représente approximativement quatre variables
candidates par division.
Cette sélection aléatoire des variables apporte une régularisation
supplémentaire et limite la dépendance du modèle à quelques prédicteurs
dominants.
La ROC-AUC moyenne obtenue en validation croisée est de :
$$
0{,}934.
$$
Cette valeur est légèrement supérieure à celle de la forêt aléatoire, qui
atteignait environ $0{,}9325$, et à celles des modèles logistiques, proches
de $0{,}925$.
Le gain par rapport à la forêt aléatoire est toutefois faible, de l’ordre de :
$$
0{,}934 - 0{,}9325 = 0{,}0015.
$$
Cet écart est trop faible pour conclure immédiatement à une supériorité nette
du Gradient Boosting. Il faudra vérifier l’écart-type de validation, les
performances sur le jeu de test et le nombre de faux négatifs.
En conclusion, la validation croisée retient un modèle composé d’un nombre
modéré d’arbres relativement profonds, avec une régularisation obtenue grâce à
l’utilisation de seulement 60 % des observations et d’un sous-ensemble des
variables à chaque étape.
À ce stade, le Gradient Boosting présente la meilleure ROC-AUC moyenne de
validation de l’étude, mais son avantage sur la forêt aléatoire reste très
modeste.
:::
## Résultats de validation croisée
```{python}
gradient_boosting_cv_results = pd.DataFrame(
gradient_boosting_search.cv_results_
)
gradient_boosting_cv_summary = (
gradient_boosting_cv_results[
[
"param_n_estimators",
"param_learning_rate",
"param_max_depth",
"param_min_samples_split",
"param_min_samples_leaf",
"param_subsample",
"param_max_features",
"mean_train_roc_auc",
"mean_test_roc_auc",
"std_test_roc_auc",
"mean_test_accuracy",
"mean_test_balanced_accuracy",
"mean_test_precision",
"mean_test_recall",
"mean_test_f1",
"rank_test_roc_auc",
]
]
.sort_values("rank_test_roc_auc")
.reset_index(drop=True)
)
display(
gradient_boosting_cv_summary
.head(20)
.round(4)
)
```
::: {.callout-note title="Interprétation de la validation croisée"}
La meilleure configuration atteint une ROC-AUC moyenne d’apprentissage de
$0{,}9888$ et une ROC-AUC moyenne de validation de $0{,}9335$.
L’écart entre ces deux performances est donc d’environ :
$$
0{,}9888 - 0{,}9335 = 0{,}0553.
$$
Cet écart indique que le Gradient Boosting s’ajuste très fortement aux données
d’apprentissage. Un certain surajustement est donc présent, mais la ROC-AUC de
validation reste élevée, ce qui montre que le modèle conserve une bonne
capacité de généralisation.
L’écart-type de validation est égal à $0{,}0263$. Les performances varient
donc modérément selon les plis de validation croisée. Cette variabilité reste
acceptable et proche de celle observée avec la forêt aléatoire.
Les meilleures configurations sont extrêmement proches. Les trois premières
obtiennent respectivement des ROC-AUC moyennes de :
- $0{,}9335$ ;
- $0{,}9333$ ;
- $0{,}9331$.
L’écart entre la première et la troisième configuration n’est que de
$0{,}0004$. Il est donc trop faible pour considérer que la première
combinaison est réellement supérieure aux suivantes.
La recherche met ainsi en évidence un plateau de performances. Plusieurs
combinaisons de `n_estimators`, `learning_rate`, `max_depth` et `subsample`
produisent pratiquement la même capacité de discrimination.
Deux stratégies principales apparaissent parmi les meilleures configurations :
- un taux d’apprentissage relativement élevé, comme `0.10`, associé à seulement
50 arbres ;
- un taux d’apprentissage plus faible, comme `0.03` ou `0.01`, associé à 150
ou 300 arbres.
Ces résultats illustrent le compromis classique entre `learning_rate` et
`n_estimators` : lorsqu’un arbre contribue moins fortement, il faut davantage
d’étapes de boosting pour obtenir une performance comparable.
Les profondeurs optimales sont généralement comprises entre 4 et 5. Des arbres
de cette profondeur peuvent représenter des interactions complexes, mais ils
augmentent également le risque de surajustement. Les contraintes sur
`min_samples_leaf`, `min_samples_split`, `subsample` et `max_features`
permettent de limiter ce risque.
Certaines configurations obtiennent une ROC-AUC d’apprentissage supérieure à
$0{,}99$ sans amélioration de la validation. Par exemple, une configuration
atteint $0{,}9966$ en apprentissage pour seulement $0{,}9318$ en
validation. Cela montre qu’une augmentation de la complexité peut surtout
améliorer l’ajustement aux données d’entraînement sans améliorer la
généralisation.
La meilleure configuration selon la ROC-AUC n’est pas celle qui maximise les
autres métriques. Par exemple, la troisième configuration atteint une accuracy
moyenne de $0{,}8720$, une sensibilité de $0{,}9139$ et un F1-score de
$0{,}8882$, légèrement supérieurs à ceux de la configuration classée
première.
Ce résultat est normal, car la sélection finale est réalisée avec la ROC-AUC
comme critère de référence. Le choix du modèle dépendrait donc légèrement de
la métrique prioritaire.
En conclusion, le Gradient Boosting présente une ROC-AUC de validation élevée
et relativement stable. Il existe un certain surajustement, mais plusieurs
configurations régularisées atteignent des performances très proches. La
configuration retenue constitue un bon compromis, sans être nettement
supérieure aux autres solutions situées sur le même plateau.
:::
## Évolution des performances selon le nombre d’arbres
Le modèle permet d’obtenir les prédictions produites après l’ajout successif
de chaque arbre.
```{python}
from sklearn.metrics import roc_auc_score
gradient_boosting_stages = []
for stage_number, (
train_probability,
test_probability,
) in enumerate(
zip(
gradient_boosting_model
.staged_predict_proba(
X_train_encoded
),
gradient_boosting_model
.staged_predict_proba(
X_test_encoded
),
),
start=1,
):
gradient_boosting_stages.append(
{
"nombre_arbres": stage_number,
"roc_auc_train":
roc_auc_score(
y_train,
train_probability[:, 1],
),
"roc_auc_test":
roc_auc_score(
y_test,
test_probability[:, 1],
),
}
)
gradient_boosting_stage_results = (
pd.DataFrame(
gradient_boosting_stages
)
)
display(
gradient_boosting_stage_results
.tail()
.round(4)
)
```
> Cette courbe utilise le jeu de test uniquement pour une visualisation
> rétrospective. Le nombre d’arbres reste sélectionné par validation croisée,
> et non à partir du maximum observé sur le test.
```{python}
#| label: fig-gradient-boosting-stages
#| fig-cap: "Évolution de la ROC-AUC selon le nombre d’arbres"
plt.figure(figsize=(9, 5))
plt.plot(
gradient_boosting_stage_results[
"nombre_arbres"
],
gradient_boosting_stage_results[
"roc_auc_train"
],
label="Apprentissage",
)
plt.plot(
gradient_boosting_stage_results[
"nombre_arbres"
],
gradient_boosting_stage_results[
"roc_auc_test"
],
label="Test",
)
plt.xlabel("Nombre d’arbres")
plt.ylabel("ROC-AUC")
plt.title(
"Évolution des performances "
"du Gradient Boosting"
)
plt.legend()
plt.tight_layout()
plt.show()
```
::: {.callout-note title="Interprétation de la progression"}
À compléter après exécution :
- nombre d’arbres à partir duquel le score se stabilise ;
- progression de la ROC-AUC d’apprentissage ;
- stabilisation ou diminution de la ROC-AUC sur le test ;
- présence éventuelle d’un surajustement tardif ;
- cohérence avec le nombre d’arbres sélectionné en validation croisée.
L’ajout d’arbres est utile tant que la performance de généralisation augmente.
Lorsque seule la performance d’apprentissage continue d’augmenter, les arbres
supplémentaires risquent surtout d’accentuer le surajustement.
:::
## Importance fondée sur l’impureté
```{python}
gradient_boosting_importance = (
pd.DataFrame(
{
"variable":
X_train_encoded.columns,
"importance":
gradient_boosting_model
.feature_importances_,
}
)
.sort_values(
"importance",
ascending=False,
)
.reset_index(drop=True)
)
display(
gradient_boosting_importance
.round(4)
)
```
```{python}
#| label: fig-gradient-boosting-importance
#| fig-cap: "Importance des variables dans le Gradient Boosting"
gradient_boosting_importance_plot = (
gradient_boosting_importance
.sort_values("importance")
)
plt.figure(figsize=(9, 7))
plt.barh(
gradient_boosting_importance_plot[
"variable"
],
gradient_boosting_importance_plot[
"importance"
],
)
plt.xlabel(
"Importance fondée sur l’impureté"
)
plt.ylabel("Variable")
plt.title(
"Importance des variables "
"— Gradient Boosting"
)
plt.tight_layout()
plt.show()
```
## Importance par permutation
```{python}
from sklearn.inspection import (
permutation_importance,
)
gradient_boosting_permutation = (
permutation_importance(
gradient_boosting_model,
X_test_encoded,
y_test,
scoring="roc_auc",
n_repeats=30,
random_state=RANDOM_STATE,
n_jobs=-1,
)
)
gradient_boosting_permutation_importance = (
pd.DataFrame(
{
"variable":
X_test_encoded.columns,
"importance_moyenne":
gradient_boosting_permutation
.importances_mean,
"ecart_type":
gradient_boosting_permutation
.importances_std,
}
)
.sort_values(
"importance_moyenne",
ascending=False,
)
.reset_index(drop=True)
)
display(
gradient_boosting_permutation_importance
.round(4)
)
```
```{python}
#| label: fig-gradient-boosting-permutation
#| fig-cap: "Importance par permutation du Gradient Boosting"
gradient_boosting_permutation_plot = (
gradient_boosting_permutation_importance
.sort_values("importance_moyenne")
)
plt.figure(figsize=(9, 7))
plt.barh(
gradient_boosting_permutation_plot[
"variable"
],
gradient_boosting_permutation_plot[
"importance_moyenne"
],
xerr=(
gradient_boosting_permutation_plot[
"ecart_type"
]
),
)
plt.axvline(
0,
linewidth=1,
)
plt.xlabel(
"Diminution moyenne de la ROC-AUC"
)
plt.ylabel("Variable")
plt.title(
"Importance par permutation "
"— Gradient Boosting"
)
plt.tight_layout()
plt.show()
```
L’importance par permutation mesure la diminution du score après mélange
aléatoire des valeurs d’une variable. Une importance faible peut aussi
apparaître lorsque plusieurs variables contiennent des informations
redondantes. :contentReference[oaicite:1]{index=1}
::: {.callout-note title="Interprétation des importances"}
Selon l’importance fondée sur la réduction de l’impureté, la variable la plus
importante du Gradient Boosting est `ST_Slope_Up`, avec une importance de
$0{,}2446$.
Elle est suivie par :
- `ExerciseAngina_Y`, avec $0{,}1250$ ;
- `Cholesterol`, avec $0{,}1048$ ;
- `MaxHR`, avec $0{,}0861$ ;
- `Oldpeak`, avec $0{,}0810$ ;
- `RestingBP`, avec $0{,}0574$ ;
- `ChestPainType_ATA`, avec $0{,}0566$.
Le modèle concentre donc une part importante de ses décisions sur
`ST_Slope_Up`, mais de manière moins extrême que l’arbre de décision unique,
dans lequel cette variable représentait environ 66,8 % de l’importance
totale.
Les importances sont également un peu plus concentrées que dans la forêt
aléatoire. Le Gradient Boosting réutilise en effet successivement les
variables les plus utiles afin de corriger les erreurs des arbres précédents.
L’importance par permutation confirme que `ST_Slope_Up` est la variable la
plus déterminante. Sa permutation diminue la ROC-AUC d’environ $0{,}0395$,
avec un écart-type de $0{,}0108$.
Les variables suivantes dont la permutation dégrade le plus les performances
sont :
- `Cholesterol`, avec une diminution moyenne de $0{,}0206$ ;
- `Sex_M`, avec $0{,}0203$ ;
- `ChestPainType_ATA`, avec $0{,}0129$ ;
- `FastingBS_1`, avec $0{,}0122$ ;
- `ExerciseAngina_Y`, avec $0{,}0094$ ;
- `Oldpeak`, avec $0{,}0088$.
Les deux méthodes sont donc cohérentes pour plusieurs variables importantes,
notamment `ST_Slope_Up`, `Cholesterol`, `ExerciseAngina_Y`, `Oldpeak` et
`ChestPainType_ATA`.
Certaines différences apparaissent toutefois. Par exemple, `MaxHR` possède une
importance relativement élevée fondée sur l’impureté, mais son importance par
permutation est faible et inférieure à son écart-type. Cela suggère que son
information est partiellement redondante avec celle d’autres variables.
De même, `Age` est régulièrement utilisée dans les arbres, mais sa permutation
n’entraîne presque aucune baisse de ROC-AUC. Elle contribue donc à certaines
règles locales sans être indispensable à la performance globale.
À l’inverse, `Sex_M` possède une importance d’impureté relativement modérée,
mais une importance par permutation élevée. Cette variable apporte donc une
information complémentaire importante, même si elle n’est pas utilisée aussi
fréquemment que d’autres dans les divisions.
`ChestPainType_NAP` présente également une importance par permutation faible et
instable, puisque son écart-type est supérieur à sa moyenne. Son rôle
prédictif propre paraît donc limité ou facilement remplacé par d’autres
variables.
La comparaison avec les modèles précédents montre une forte cohérence. Les
variables `ST_Slope_Up`, `Sex_M`, `ExerciseAngina_Y`, `FastingBS_1`,
`Oldpeak`, `MaxHR`, `Cholesterol` et les modalités de `ChestPainType`
figuraient déjà parmi les variables importantes des régressions logistiques,
de l’arbre et de la forêt aléatoire.
Le Gradient Boosting accorde toutefois une place particulièrement importante
à `ST_Slope_Up` et à `ExerciseAngina_Y`. Il semble donc exploiter fortement les
interactions entre les résultats liés à l’effort, la pente du segment ST et
les autres caractéristiques cliniques.
En conclusion, le Gradient Boosting repose principalement sur
`ST_Slope_Up`, puis sur `Cholesterol`, `Sex_M`, `ExerciseAngina_Y`,
`ChestPainType_ATA`, `FastingBS_1` et `Oldpeak`. Les importances sont moins
dominées par une seule variable que dans l’arbre unique, mais restent plus
concentrées que dans la forêt aléatoire.
:::
## Prédictions sur le jeu de test
```{python}
y_pred_gradient_boosting = (
gradient_boosting_model.predict(
X_test_encoded
)
)
y_proba_gradient_boosting = (
gradient_boosting_model
.predict_proba(
X_test_encoded
)[:, 1]
)
```
## Indicateurs du modèle
```{python}
(
tn_gradient_boosting,
fp_gradient_boosting,
fn_gradient_boosting,
tp_gradient_boosting,
) = confusion_matrix(
y_test,
y_pred_gradient_boosting,
).ravel()
sensitivity_gradient_boosting = (
tp_gradient_boosting
/
(
tp_gradient_boosting
+ fn_gradient_boosting
)
)
specificity_gradient_boosting = (
tn_gradient_boosting
/
(
tn_gradient_boosting
+ fp_gradient_boosting
)
)
gradient_boosting_metrics = pd.Series(
{
"Accuracy":
accuracy_score(
y_test,
y_pred_gradient_boosting,
),
"Balanced accuracy":
balanced_accuracy_score(
y_test,
y_pred_gradient_boosting,
),
"Précision":
precision_score(
y_test,
y_pred_gradient_boosting,
),
"Sensibilité / Recall":
sensitivity_gradient_boosting,
"Spécificité":
specificity_gradient_boosting,
"F1-score":
f1_score(
y_test,
y_pred_gradient_boosting,
),
"ROC-AUC":
roc_auc_score(
y_test,
y_proba_gradient_boosting,
),
"Average Precision":
average_precision_score(
y_test,
y_proba_gradient_boosting,
),
},
name="Gradient Boosting",
)
display(
gradient_boosting_metrics
.to_frame("score")
.round(3)
)
```
## Rapport de classification
```{python}
print(
classification_report(
y_test,
y_pred_gradient_boosting,
target_names=[
"Absence de maladie",
"Présence de maladie",
],
digits=3,
)
)
```
## Matrice de confusion
```{python}
#| label: fig-confusion-gradient-boosting
#| fig-cap: "Matrice de confusion du Gradient Boosting"
ConfusionMatrixDisplay.from_predictions(
y_test,
y_pred_gradient_boosting,
display_labels=[
"Absence",
"Présence",
],
)
plt.title(
"Matrice de confusion "
"— Gradient Boosting"
)
plt.tight_layout()
plt.show()
```
::: {.callout-important title="Interprétation de la matrice de confusion"}
- vrais positifs : `{python} tp_gradient_boosting` ;
- vrais négatifs : `{python} tn_gradient_boosting` ;
- faux positifs : `{python} fp_gradient_boosting` ;
- faux négatifs : `{python} fn_gradient_boosting`.
Le modèle identifie correctement 95 des 102 patients présentant réellement
une maladie cardiaque. Sa sensibilité atteint donc 93,1 %.
Seulement 7 patients malades sont classés à tort comme non malades. Le nombre
de faux négatifs est ainsi identique à celui des modèles logistiques et
inférieur à celui de la forêt aléatoire, qui en produisait 8.
Parmi les 82 patients sans maladie, 70 sont correctement classés et 12 sont
prédits à tort comme positifs. La spécificité atteint ainsi 85,4 %, soit une
amélioration par rapport aux modèles logistiques et à la forêt aléatoire, qui
obtenaient une spécificité de 82,9 %.
Les performances globales sont :
- accuracy : 89,7 % ;
- balanced accuracy : 89,3 % ;
- précision : 88,8 % ;
- sensibilité : 93,1 % ;
- spécificité : 85,4 % ;
- F1-score : 0,909 ;
- ROC-AUC : 0,941 ;
- Average Precision : 0,943.
Ces résultats sont les meilleurs obtenus jusqu’à présent sur le jeu de test.
Le Gradient Boosting conserve la même capacité de détection des cas positifs
que les régressions logistiques, tout en réduisant le nombre de faux positifs
de 14 à 12.
Par rapport à la forêt aléatoire, il produit :
- un faux négatif de moins ;
- deux faux positifs de moins ;
- une accuracy supérieure ;
- une ROC-AUC supérieure, passant de 0,931 à 0,941 ;
- une Average Precision supérieure, passant de 0,930 à 0,943.
La balanced accuracy de 89,3 % montre également que les performances restent
bien équilibrées entre les deux classes.
En conclusion, le Gradient Boosting offre ici le meilleur compromis prédictif
au seuil de 0,5. Il améliore la discrimination globale, la précision, la
spécificité et le F1-score, sans dégrader la sensibilité ni augmenter le nombre
de faux négatifs.
:::
## Courbe ROC
```{python}
#| label: fig-roc-gradient-boosting
#| fig-cap: "Courbe ROC du Gradient Boosting"
gradient_boosting_roc_auc = (
roc_auc_score(
y_test,
y_proba_gradient_boosting,
)
)
RocCurveDisplay.from_predictions(
y_test,
y_proba_gradient_boosting,
name=(
"Gradient Boosting — "
f"AUC = "
f"{gradient_boosting_roc_auc:.3f}"
),
)
plt.plot(
[0, 1],
[0, 1],
linestyle="--",
label="Modèle aléatoire",
)
plt.title(
"Courbe ROC — Gradient Boosting"
)
plt.xlabel("Taux de faux positifs")
plt.ylabel("Taux de vrais positifs")
plt.legend()
plt.tight_layout()
plt.show()
```
## Courbe précision-rappel
```{python}
#| label: fig-pr-gradient-boosting
#| fig-cap: "Courbe précision-rappel du Gradient Boosting"
gradient_boosting_average_precision = (
average_precision_score(
y_test,
y_proba_gradient_boosting,
)
)
PrecisionRecallDisplay.from_predictions(
y_test,
y_proba_gradient_boosting,
name=(
"Gradient Boosting — "
f"AP = "
f"{gradient_boosting_average_precision:.3f}"
),
)
plt.title(
"Courbe précision-rappel "
"— Gradient Boosting"
)
plt.xlabel("Rappel")
plt.ylabel("Précision")
plt.legend()
plt.tight_layout()
plt.show()
```
## Comparaison avec les modèles précédents
```{python}
model_comparison_with_boosting = pd.concat(
[
logistic_metrics.rename(
"Non pénalisée"
),
ridge_metrics.rename(
"Ridge"
),
lasso_metrics.rename(
"Lasso"
),
elastic_metrics.rename(
"Elastic Net"
),
tree_metrics.rename(
"Arbre de décision"
),
random_forest_metrics.rename(
"Forêt aléatoire"
),
gradient_boosting_metrics.rename(
"Gradient Boosting"
),
],
axis=1,
)
model_comparison_with_boosting[
"Différence boosting vs logistique"
] = (
model_comparison_with_boosting[
"Gradient Boosting"
]
-
model_comparison_with_boosting[
"Non pénalisée"
]
)
model_comparison_with_boosting[
"Différence boosting vs forêt"
] = (
model_comparison_with_boosting[
"Gradient Boosting"
]
-
model_comparison_with_boosting[
"Forêt aléatoire"
]
)
display(
model_comparison_with_boosting
.round(3)
)
```
::: {.callout-important title="Conclusion du Gradient Boosting"}
Le Gradient Boosting obtient les meilleures performances globales parmi les
modèles évalués.
Sur le jeu de test, il atteint une accuracy de $0{,}897$, contre $0{,}886$
pour les régressions logistiques, $0{,}880$ pour la forêt aléatoire et
$0{,}832$ pour l’arbre de décision.
La balanced accuracy s’élève à $0{,}893$. Elle dépasse de $0{,}012$ celle
des modèles logistiques et de $0{,}017$ celle de la forêt aléatoire. Cette
amélioration indique que le modèle offre de meilleures performances moyennes
sur les deux classes.
La sensibilité reste égale à $0{,}931$, soit le même niveau que les
régressions logistiques. Le modèle conserve donc leur bonne capacité à détecter
les patients malades. Il produit 7 faux négatifs, contre 8 pour la forêt
aléatoire et 15 pour l’arbre de décision.
L’amélioration provient principalement de la spécificité, qui passe de
$0{,}829$ avec les modèles logistiques et la forêt à $0{,}854$. Le Gradient
Boosting réduit ainsi le nombre de faux positifs de 14 à 12 sans augmenter le
nombre de faux négatifs.
La précision atteint $0{,}888$ et le F1-score $0{,}909$, soit les
meilleures valeurs observées dans cette étude. Le modèle réalise donc un
meilleur compromis entre la précision des prédictions positives et la
détection des cas réellement positifs.
La ROC-AUC atteint $0{,}941$, soit :
- $0{,}012$ de plus que la régression logistique non pénalisée ;
- $0{,}011$ de plus que Ridge ;
- $0{,}010$ de plus que la forêt aléatoire ;
- $0{,}065$ de plus que l’arbre de décision.
L’Average Precision atteint également sa valeur maximale, avec $0{,}943$.
Le Gradient Boosting produit donc le meilleur classement probabiliste des
observations, aussi bien sur l’ensemble des seuils ROC que pour la classe
positive.
Ces gains restent modérés par rapport aux modèles logistiques et à la forêt
aléatoire. Ils ne suffisent pas, sur un seul jeu de test, à démontrer une
supériorité certaine. Ils sont toutefois cohérents avec la validation croisée,
dans laquelle le Gradient Boosting obtenait également la meilleure ROC-AUC
moyenne, proche de $0{,}934$.
Le modèle présente un certain surajustement, puisque sa ROC-AUC d’apprentissage
est nettement supérieure à celle de validation. Néanmoins, les performances de
validation restent élevées et les résultats sur le jeu de test confirment une
bonne généralisation.
En conclusion, le Gradient Boosting constitue à ce stade le modèle prédictif
le plus performant de l’étude. Il améliore la discrimination, la précision et
la spécificité, tout en conservant la sensibilité élevée des régressions
logistiques.
Le choix final dépend toutefois de l’objectif :
- pour maximiser la performance prédictive, le Gradient Boosting est le meilleur
candidat ;
- pour privilégier l’interprétabilité et la simplicité, la régression
logistique Ridge reste une solution très compétitive ;
- pour modéliser des relations non linéaires avec une méthode plus robuste
qu’un arbre unique, la forêt aléatoire reste une alternative pertinente.
:::
# Conclusion générale
Cette étude avait pour objectif de construire et de comparer plusieurs modèles
de classification capables de prédire la présence d’une maladie cardiaque à
partir de caractéristiques cliniques et démographiques.
Le jeu de données comportait 918 observations et une variable cible binaire,
`HeartDisease`. La classe positive représentait environ 55 % des observations,
ce qui correspond à un déséquilibre modéré mais non extrême.
Après préparation des données, plusieurs familles de modèles ont été étudiées :
- régression logistique non pénalisée ;
- régression logistique Ridge ;
- régression logistique Lasso ;
- régression logistique Elastic Net ;
- arbre de décision ;
- forêt aléatoire ;
- Gradient Boosting.
## Résultats des modèles logistiques
Les quatre régressions logistiques ont obtenu des performances presque
identiques sur le jeu de test.
Elles atteignent notamment :
- une accuracy de 88,6 % ;
- une sensibilité de 93,1 % ;
- une spécificité de 82,9 % ;
- un F1-score de 0,900 ;
- une ROC-AUC comprise entre 0,929 et 0,930.
Leur matrice de confusion est identique, avec 95 vrais positifs, 68 vrais
négatifs, 14 faux positifs et 7 faux négatifs.
Ridge présente un très léger avantage sur la ROC-AUC et l’Average Precision,
mais les écarts sont trop faibles pour conclure à une supériorité nette.
Lasso et Elastic Net n’ont annulé aucun coefficient. Dans cette étude, la
régularisation n’a donc pas permis de sélectionner un sous-ensemble plus réduit
de variables.
Les principaux facteurs associés aux prédictions des modèles logistiques sont
notamment :
- `ST_Slope_Up` ;
- les modalités de `ChestPainType` ;
- `Sex_M` ;
- `FastingBS_1` ;
- `ExerciseAngina_Y` ;
- `ST_Slope_Flat` ;
- `Cholesterol` ;
- `Oldpeak`.
Les signes et la hiérarchie générale des coefficients sont restés stables
entre les différentes formes de régularisation.
## Résultats de l’arbre de décision
L’arbre optimal possède une profondeur de 5 et 21 feuilles. Il permet de
représenter directement des règles non linéaires et des interactions entre les
variables.
La première séparation repose sur `ST_Slope_Up`, suivie notamment de `MaxHR`,
`Cholesterol`, `Sex_M` et `ExerciseAngina_Y`.
Cette interprétabilité s’accompagne toutefois d’une baisse importante des
performances :
- accuracy de 83,2 % ;
- sensibilité de 85,3 % ;
- ROC-AUC de 0,876 ;
- 15 faux négatifs.
L’arbre unique est donc moins performant que les modèles logistiques et
présente un risque plus élevé de manquer des patients réellement malades.
## Résultats de la forêt aléatoire
La forêt aléatoire améliore nettement les résultats de l’arbre unique.
Elle obtient :
- une ROC-AUC moyenne de validation de 0,933 ;
- une accuracy de test de 88,0 % ;
- une sensibilité de 92,2 % ;
- une ROC-AUC de test de 0,931 ;
- 8 faux négatifs.
Ses performances sont proches de celles des modèles logistiques. Elle modélise
davantage de relations non linéaires et d’interactions, mais elle reste plus
difficile à expliquer.
Les importances des variables sont également plus réparties que dans l’arbre
unique. Les variables les plus utiles comprennent notamment `ST_Slope_Up`,
`Cholesterol`, `Sex_M`, `ExerciseAngina_Y`, `Oldpeak`, `MaxHR` et
`ChestPainType_ATA`.
## Résultats du Gradient Boosting
Le Gradient Boosting obtient les meilleures performances globales de l’étude.
Sa configuration optimale utilise :
- 50 arbres ;
- un taux d’apprentissage de 0,1 ;
- une profondeur maximale de 5 ;
- un sous-échantillonnage de 60 % ;
- environ quatre variables examinées à chaque séparation.
Il atteint une ROC-AUC moyenne de validation d’environ 0,934, soit la meilleure
valeur parmi les modèles évalués.
Sur le jeu de test, il obtient :
- une accuracy de 89,7 % ;
- une balanced accuracy de 89,3 % ;
- une précision de 88,8 % ;
- une sensibilité de 93,1 % ;
- une spécificité de 85,4 % ;
- un F1-score de 0,909 ;
- une ROC-AUC de 0,941 ;
- une Average Precision de 0,943.
Sa matrice de confusion contient 95 vrais positifs, 70 vrais négatifs,
12 faux positifs et 7 faux négatifs.
Le Gradient Boosting conserve donc la sensibilité élevée des modèles
logistiques tout en réduisant le nombre de faux positifs. Il produit les
meilleures valeurs de ROC-AUC, d’Average Precision, d’accuracy et de F1-score.
## Comparaison finale
Les résultats mettent en évidence trois conclusions principales.
Premièrement, les modèles logistiques constituent des solutions très solides.
Ils sont simples, interprétables, stables et présentent une excellente
sensibilité. Leur performance reste proche de celle des méthodes d’ensemble.
Deuxièmement, l’arbre de décision unique est utile pour comprendre les règles
et les interactions, mais il n’est pas suffisamment performant pour être retenu
comme modèle prédictif final.
Troisièmement, les méthodes d’ensemble améliorent nettement l’arbre unique. La
forêt aléatoire atteint les performances des modèles logistiques, tandis que
le Gradient Boosting les dépasse légèrement sur la plupart des indicateurs.
## Modèle retenu
Le **Gradient Boosting** constitue le meilleur candidat lorsque l’objectif
principal est de maximiser les performances prédictives.
Il présente le meilleur compromis entre :
- discrimination globale ;
- sensibilité ;
- spécificité ;
- précision ;
- nombre de faux négatifs ;
- stabilité en validation croisée.
Son avantage reste cependant modéré par rapport aux modèles logistiques. La
différence de performance ne doit donc pas être interprétée comme une
supériorité absolue, notamment parce que l’évaluation repose sur un jeu de test
de taille limitée.
Dans un contexte où l’explicabilité est prioritaire, la régression logistique
Ridge reste une excellente alternative. Elle offre des performances très
proches, avec une interprétation directe des coefficients et des odds ratios.
## Limites de l’étude
Plusieurs limites doivent être prises en compte.
Le nombre d’observations reste relativement modeste. Les estimations de
performance peuvent donc varier selon le découpage des données.
Certaines variables, notamment `Cholesterol` et `RestingBP`, contiennent des
valeurs nulles ou très faibles susceptibles de représenter des données
manquantes codées numériquement. Leur traitement pourrait modifier les
résultats.
La sélection des hyperparamètres a été réalisée par validation croisée, mais
le jeu de test a servi à comparer plusieurs modèles. Une validation externe ou
une validation croisée imbriquée permettrait d’obtenir une estimation encore
plus rigoureuse.
Les probabilités prédites n’ont pas encore été étudiées en profondeur du point
de vue de leur calibration. Un modèle performant en ROC-AUC ne produit pas
nécessairement des probabilités parfaitement fiables.
Enfin, les relations identifiées sont prédictives et associatives. Elles ne
permettent pas d’établir des relations causales entre les caractéristiques
cliniques et la maladie cardiaque.
## Perspectives
Pour prolonger cette étude, plusieurs analyses pourraient être ajoutées :
- validation croisée imbriquée ;
- analyse de la calibration et du score de Brier ;
- optimisation du seuil de décision selon le coût des faux négatifs ;
- interprétation locale avec SHAP ;
- comparaison avec XGBoost, LightGBM ou CatBoost ;
- traitement explicite des valeurs manquantes codées par zéro ;
- validation sur un jeu de données externe ;
- estimation d’intervalles de confiance par bootstrap.
## Conclusion finale
Cette étude montre que plusieurs approches permettent de prédire efficacement
la présence d’une maladie cardiaque.
Les régressions logistiques offrent le meilleur niveau d’interprétabilité avec
des performances élevées. Les méthodes d’ensemble permettent de modéliser des
relations plus complexes et améliorent les résultats de l’arbre de décision
unique.
Parmi les modèles étudiés, le Gradient Boosting obtient les meilleures
performances globales. Il est donc retenu comme modèle prédictif final, tandis
que la régression logistique Ridge constitue le meilleur modèle de référence
interprétable.
Ces résultats devront néanmoins être confirmés par une validation externe et
ne doivent pas être utilisés seuls pour prendre une décision médicale.