---
title: "Partie 6 : k Plus Proches Voisins (kNN)"
---
```{r}
#| label: setup
#| include: false
#| cache: false
source(here::here("utils.R"))
```
```{r}
#| label: load
#| include: false
list2env(load_train_test(), envir = environment())
model_logistic <- readRDS(here::here("output", "model_logistic.rds"))
model_rf <- readRDS(here::here("output", "model_rf.rds"))
```
# k Plus Proches Voisins (kNN)
## Principe
::: {.callout-note title="Classifieur paresseux par voisinage"}
Le kNN (Cover & Hart, 1967) est un classificateur **non-paramétrique** et **paresseux** (*lazy learner*) : il ne construit pas de modèle explicite, mais classe chaque nouvelle observation selon le **vote majoritaire** de ses $k$ voisins les plus proches dans l'espace des features. La distance euclidienne est utilisée après standardisation.
:::
**Avantages théoriques** : aucune hypothèse sur la distribution des données, capture les frontières de décision non-linéaires complexes.
::: {.callout-warning title="Malédiction de la dimensionnalité (Séance 5)"}
Avec `r length(feature_cols)` features dont la majorité sont binaires (0/1), les distances euclidiennes deviennent peu discriminantes en haute dimension : toutes les paires d'observations tendent vers une distance similaire et la notion de « voisin proche » perd son sens. C'est la **malédiction de la dimensionnalité** étudiée en Séance 5. Nous vérifions empiriquement ce phénomène théorique au cours du tuning.
:::
## Entraînement et tuning
Nous testons 8 valeurs de $k \in \{3, 5, 7, 11, 15, 21, 31, 51\}$, couvrant les voisinages locaux (k=3, risque de surapprentissage) aux voisinages globaux (k=51, lissage maximal). La standardisation (centrage + réduction) est appliquée pour que les distances ne soient pas dominées par `Rating_Count` (échelle ~0--10 000) par rapport aux variables binaires (0/1).
```{r}
#| label: knn-train
#| output: false
knn_file <- here::here("output", "model_knn.rds")
cm_knn_file <- here::here("output", "cm_knn.rds")
roc_knn_file <- here::here("output", "roc_knn.rds")
if (file.exists(knn_file) && file.exists(cm_knn_file) && file.exists(roc_knn_file)) {
model_knn <- readRDS(knn_file)
cm_knn <- readRDS(cm_knn_file)
roc_knn <- readRDS(roc_knn_file)
} else {
grid_knn <- expand.grid(k = c(3, 5, 7, 11, 15, 21, 31, 51))
set.seed(42)
model_knn <- train(
Satisfaction ~ ., data = train_data, method = "knn",
trControl = CV_CONTROL, tuneGrid = grid_knn, metric = "ROC",
preProcess = c("center", "scale")
)
pred_knn <- predict(model_knn, test_data)
prob_knn <- predict(model_knn, test_data, type = "prob")
cm_knn <- confusionMatrix(pred_knn, test_data$Satisfaction, positive = "Oui")
roc_knn <- roc(test_data$Satisfaction, prob_knn$Oui, quiet = TRUE)
saveRDS(model_knn, knn_file)
saveRDS(cm_knn, cm_knn_file)
saveRDS(roc_knn, roc_knn_file)
}
```
### Sélection de k
```{r}
#| label: fig-knn-tuning
#| fig-cap: "AUC en validation croisée selon k — comparaison avec LR et RF"
#| fig-height: 3
knn_res <- model_knn$results
ggplot(knn_res, aes(x = k, y = ROC)) +
geom_line(color = COL_KNN, linewidth = 1) +
geom_point(color = COL_KNN, size = 3) +
geom_errorbar(aes(ymin = ROC - ROCSD, ymax = ROC + ROCSD),
width = 2, color = COL_KNN, alpha = 0.5) +
geom_hline(yintercept = max(model_logistic$results$ROC),
linetype = "dashed", color = COL_PRIMARY, linewidth = 0.7) +
geom_hline(yintercept = max(model_rf$results$ROC),
linetype = "dashed", color = COL_POSITIVE, linewidth = 0.7) +
annotate("text", x = 45, y = max(model_logistic$results$ROC) + 0.003,
label = "Logistique (CV)", color = COL_PRIMARY, size = 3) +
annotate("text", x = 45, y = max(model_rf$results$ROC) + 0.003,
label = "Forêt Aléat. (CV)", color = COL_POSITIVE, size = 3) +
scale_x_continuous(breaks = c(3, 5, 7, 11, 15, 21, 31, 51)) +
labs(x = "Nombre de voisins (k)", y = "AUC (CV 5-fold)",
title = "Performance kNN selon k") +
THEME_REPORT
```
L'AUC augmente **monotoniquement** avec $k$, passant de `r round(min(knn_res$ROC), 3)` (k=3) à `r round(max(knn_res$ROC), 3)` (k=51). Cette tendance est révélatrice : le modèle bénéficie systématiquement du lissage, ce qui indique que le **signal local est noyé dans le bruit dimensionnel**. Même au meilleur k=51, le kNN reste en-dessous des deux lignes de référence (logistique et forêt aléatoire).
### Performance sur le jeu test
```{r}
#| label: tbl-knn-metrics
#| tbl-cap: "Métriques finales du kNN"
kable(data.frame(
Metrique = c("Meilleur k", "AUC (CV)", "AUC (test)", "Accuracy",
"Sensibilité", "Spécificité", "F1"),
Valeur = c(
model_knn$bestTune$k,
round(max(model_knn$results$ROC), 4),
round(auc(roc_knn), 4),
round(cm_knn$overall["Accuracy"], 4),
round(cm_knn$byClass["Sensitivity"], 4),
round(cm_knn$byClass["Specificity"], 4),
round(cm_knn$byClass["F1"], 4)
)
))
```
### Matrice de confusion
```{r}
#| label: fig-cm-knn
#| fig-cap: "Matrice de confusion — kNN"
#| fig-height: 2.8
#| fig-width: 3.5
#| out-width: "50%"
cm_table_knn <- as.data.frame(cm_knn$table)
ggplot(cm_table_knn, aes(x = Reference, y = Prediction, fill = Freq)) +
geom_tile(color = "white") +
geom_text(aes(label = Freq), size = 6, fontface = "bold") +
scale_fill_gradient(low = "white", high = COL_KNN) +
labs(x = "Réel", y = "Prédit", title = "Matrice de confusion — kNN") +
THEME_REPORT + theme(legend.position = "none")
```
### Diagnostic : la malédiction de la dimensionnalité
Le fait que k=51 soit optimal (le plus grand testé) est révélateur : avec `r length(feature_cols)` dimensions dont ~50 binaires, les distances euclidiennes se concentrent autour de leur moyenne et les observations deviennent quasiment **équidistantes**. Le kNN perd sa capacité discriminante car la notion de « voisinage local » n'a plus de sens en haute dimension. C'est un cas d'école de la **malédiction de la dimensionnalité** étudiée en **Séance 5** : le kNN a besoin d'un voisinage très large pour moyenner le bruit, ce qui revient à une classification quasi-globale — perdant l'avantage principal de la méthode.
> **Bilan** — kNN retenu pour illustration pédagogique, AUC test = `r round(auc(roc_knn), 3)` (k = `r model_knn$bestTune$k`). Démontre empiriquement la malédiction de la dimensionnalité.