---
ukp: "Template ACP+HCPC v4. DRY via prepare_pca_hcpc + compute_acp_metrics (jcn-dml-grid-acp.R). CONFIG+DDICT, biplot_dual_height auto, coord_cartesian clusters, Mahalanobis actives, protections >5000 ind., notes de lecture."
dcr: 26-04-15_1900
dup: 26-06-18
tags:
- fd
- fd/DML
- fj/jQUARTO
- fj/jRR
- zfd/quarto-template-acp-hcpc
tl: "tpl-nbk-acp-hcpc-dml-jRR-jquarto-fd"
title: "ACP-HCPC — Pbnb 76 villes monde"
subtitle: "76 villes Airbnb · KPI juin 2026 · évolution 2025→2026 en supplémentaires"
categories: ["Notebook"]
date: today
lang: fr
from: markdown+mark
# Config HTML héritée de tpl-templates-nbk/_quarto.yml (conv 260613 : theme pbnb-v2,
# banner #454b54, toc right, body 1000 + gutter 2rem, fig-dpi 250, embed:false).
# Override local : fig 12×8 (biplots ACP plus grands que le défaut 10×6).
format:
html:
fig-width: 12
fig-height: 8
execute:
cache: true
---
```{r}
#| label: config
#| code-fold: true
#| cache: false
#| message: false
# ============================================================
# CONFIG — MODIFIER UNIQUEMENT CE BLOC
# ============================================================
# Dictionnaire : noms R → labels FR
DDICT <- c(
var1 = "Description variable 1",
var2 = "Description variable 2"
)
CONFIG <- list(
# --- Données ---
data_path = "chemin/vers/data.csv",
encoding = "UTF-8-BOM",
label_col = "libelle",
filter_expr = NULL,
# --- Variables ACP ---
actives = c("var1", "var2", "var3"),
sup_quanti = c("sup1"),
sup_quali = c("f_group"),
transform = "none", # "none", "log", "winsor_p5p95", ou c("var1","var3")
# --- HCPC ---
k = 4,
# k_alt : vecteur d'entiers a benchmarker (ex c(3,4,5)). Si k = NULL et k_alt
# fourni, le k retenu = celui de meilleure silhouette HCPC. Logue le bench.
k_alt = NULL,
# --- Affichage ---
size_col = NULL,
top_n = 20,
selected = "B",
# --- Grille (optionnel) ---
run_grid = FALSE,
grid_configs = list(),
# --- Nettoyage redondance (Kaiser 1970, MSA min) ---
# threshold_drop : seuil |r| au-dessus duquel une variable de la paire est
# retirée itérativement. 1.0 = désactivé (défaut rétro-compat). 0.90 recommandé.
# force_keep : variables protégées (jamais retirées par le pipeline).
threshold_drop = 1.0,
force_keep = NULL,
# --- Ordre des clusters ---
# cluster_order : "size" = relabel par taille décroissante (C1 = modal) — stable
# et lisible ; NULL = ordre natif FactoMineR (position Dim1).
cluster_order = "size",
# --- Biplots clusters (§6) ---
# cluster_plane_13 : TRUE = afficher les 2 plans (1-2 ET 1-3) côte à côte ;
# FALSE = n'afficher que le plan 1-2. AUTO : forcé à FALSE si
# < 3 axes retenus (Kaiser) — voir chunk calc-all.
cluster_plane_13 = TRUE
)
# ============================================================
# OVERRIDE CSV ddict (mode CSV-driven, optionnel)
# ============================================================
# Si params$ddict_csv_path est fourni au render (-P ddict_csv_path=...),
# alors DDICT et GRID_CONFIGS sont charges depuis le CSV, et CONFIG hardcoded
# ci-dessus est ECRASE pour les champs concernes (actives, sup_quanti, k...).
# La config selectionnee est : params$config_selected si fournie, sinon la 1ere du grid CSV.
# CONFIG$data_path est remplace par params$data_csv_path si fourni.
# Helper : recupere param via env var (TPLJ_DDICT, TPLJ_DATA, TPLJ_SEL, TPLJ_LABEL)
# Plus robuste sous Windows que les params Quarto en chunks knitr.
.get_env <- function(name) {
v <- Sys.getenv(name, unset = "")
if (!nzchar(v)) return(NULL)
v
}
.p_ddict <- .get_env("TPLJ_DDICT")
.p_data <- .get_env("TPLJ_DATA")
.p_sel <- .get_env("TPLJ_SEL")
.p_label <- .get_env("TPLJ_LABEL")
.p_method <- .get_env("TPLJ_METHOD") # "hcpc" (defaut) ou "kmeans" pour skip CAH
if (!is.null(.p_ddict) && file.exists(.p_ddict)) {
source("C:/Users/vince/hh/pq/PDS/mutils/jrr/jcn-dml-grid-acpols-ddict.R")
DDICT <- load_ddict_from_csv(.p_ddict)
# DDICT_DF = le ddict CSV complet (data.frame) -> permet à build_config_pivot d'utiliser
# le label MEDIUM (col `medium`) plutôt que short, + theme/unit. Fallback short si absent.
DDICT_DF <- tryCatch(read.csv(.p_ddict, stringsAsFactors = FALSE, check.names = FALSE,
fileEncoding = "UTF-8-BOM"),
error = function(e) tryCatch(read.csv(.p_ddict, stringsAsFactors = FALSE, check.names = FALSE),
error = function(e2) NULL))
GRID_CONFIGS <- load_grid_from_ddict(.p_ddict, method = "acp")
sel_target <- if (!is.null(.p_sel)) .p_sel else GRID_CONFIGS[[1]]$id
cfg_sel <- NULL
for (cfg in GRID_CONFIGS) {
if (identical(cfg$id, sel_target) || endsWith(cfg$id, sel_target)) {
cfg_sel <- cfg; break
}
}
if (is.null(cfg_sel)) cfg_sel <- GRID_CONFIGS[[1]]
if (!is.null(.p_data)) {
CONFIG$data_path <- .p_data
CONFIG$encoding <- "UTF-8"
}
if (!is.null(.p_label)) {
CONFIG$label_col <- .p_label
}
CONFIG$actives <- cfg_sel$actives
CONFIG$sup_quanti <- cfg_sel$sup_quanti
CONFIG$sup_quali <- cfg_sel$sup_quali
CONFIG$transform <- cfg_sel$transform
CONFIG$k <- cfg_sel$k
CONFIG$k_alt <- cfg_sel$k_alt
CONFIG$kk <- cfg_sel$kk # pre-k-means HCPC pour gros datasets (n > 5000)
CONFIG$selected <- cfg_sel$id
CONFIG$grid_configs <- GRID_CONFIGS
CONFIG$run_grid <- TRUE
# Method global (s'applique a toutes les configs du grid) :
# "hcpc" (defaut) ou "kmeans" pour skip CAH (rapide, scale n large)
CONFIG$method <- if (!is.null(.p_method))
tolower(.p_method) else "hcpc"
message(sprintf("Mode CSV-driven actif - config selectionnee : %s (%d actives) - method=%s",
cfg_sel$id, length(cfg_sel$actives), CONFIG$method))
}
```
```{r}
#| label: setup
#| include: false
#| cache: false
library(dplyr)
library(FactoMineR)
library(factoextra)
library(cluster)
library(ggplot2)
library(patchwork)
library(reactable)
JCN <- "C:/Users/vince/hh/pq/PDS/mutils/jrr"
source(file.path(JCN, "jcn-all.R"))
source(file.path(JCN, "jcn-dml-acp-plots.R"))
source(file.path(JCN, "jcn-dml-hcpc-typo.R"))
source(file.path(JCN, "jcn-dml-grid-acp.R"))
source(file.path(JCN, "jcn-claude-log.R"))
source(file.path(JCN, "jcn-eda-summary.R")) # calc_eda_summary pour log_eda_digest
source(file.path(JCN, "jcn-eda-corr.R")) # calc_corr_diag, calc_eta2_table
source(file.path(JCN, "jcn-ddict.R")) # load_kpi_ddict() pour render_acp_metrics_glossary()
# Initialiser le log Claude (JSON dans LOG_OUT_DIR)
# Mode CSV-driven sandbox pzm : LOG_NAME = "{basename_data}-acp-{cfg_id}"
# LOG_OUT_DIR = "{dirname(TPLJ_DATA)}/rsl"
# Mode hardcoded : nom generique + dossier reports/outputs/
.lp_ddict <- { v <- Sys.getenv("TPLJ_DDICT", unset = ""); if (nzchar(v)) v else NULL }
.lp_data <- { v <- Sys.getenv("TPLJ_DATA", unset = ""); if (nzchar(v)) v else NULL }
.lp_sel <- { v <- Sys.getenv("TPLJ_SEL", unset = ""); if (nzchar(v)) v else NULL }
if (!is.null(.lp_data)) {
.data_stem <- tools::file_path_sans_ext(basename(.lp_data))
.cfg_id <- if (!is.null(.lp_sel)) .lp_sel else "auto"
LOG_NAME <- sprintf("%s-acp-%s", .data_stem, .cfg_id)
.rsl_dir <- file.path(dirname(.lp_data), "rsl")
if (!dir.exists(.rsl_dir)) dir.create(.rsl_dir, recursive = TRUE)
LOG_OUT_DIR <- .rsl_dir
} else {
LOG_NAME <- "tpljcn-acp"
LOG_OUT_DIR <- "reports/outputs"
}
claude_log_init(LOG_NAME, output_dir = LOG_OUT_DIR)
```
```{r}
#| label: calc-all
#| include: false
#| cache: true
# &s &CALC_ALL_aaMAIN - Données, ACP+HCPC, métriques (DRY via jcn-dml-grid-acp)
# &s &LOAD_DATA - Lecture CSV + filtre + complete.cases
# check.names = FALSE : préserve les noms de colonnes commençant par un chiffre
# (ex vars 100m/400m/1500m commençant par un chiffre) qui sinon deviennent X100m… et ne matchent plus
# les actives du ddict.
df_raw <- read.csv(CONFIG$data_path,
stringsAsFactors = FALSE, fileEncoding = CONFIG$encoding, check.names = FALSE)
if (!is.null(CONFIG$filter_expr) && nzchar(CONFIG$filter_expr)) {
df <- df_raw %>% filter(eval(parse(text = CONFIG$filter_expr)))
} else {
df <- df_raw
}
df <- df[complete.cases(df[, CONFIG$actives, drop = FALSE]), ]
# Fallback label_col : si non défini / absent du df, prendre la colonne TEXTE la
# plus discriminante (>= 50% de valeurs uniques) hors variables ACP. Évite les
# rownames numériques (1,2,3…) sur plots/cartes (parangons, biplot, cartes cluster).
if (is.null(CONFIG$label_col) || !(CONFIG$label_col %in% names(df))) {
.cand <- setdiff(names(df), c(CONFIG$actives, CONFIG$sup_quanti))
.best <- NULL; .best_card <- -1
for (.v in .cand) {
.x <- df[[.v]]
if (is.character(.x) || is.factor(.x)) {
.nu <- length(unique(.x[!is.na(.x)]))
if (.nu >= 0.5 * nrow(df) && .nu > .best_card) { .best <- .v; .best_card <- .nu }
}
}
if (!is.null(.best)) {
CONFIG$label_col <- .best
message(sprintf("label_col auto-détecté : '%s' (%d valeurs uniques)", .best, .best_card))
}
}
# Deduplication label_col : si la colonne label contient des doublons (cas
# Communities : 'Springfield' dans plusieurs states), suffixer avec _N pour
# garantir des rownames uniques requis par FactoMineR / HCPC.
if (!is.null(CONFIG$label_col) && CONFIG$label_col %in% names(df)) {
lbls <- as.character(df[[CONFIG$label_col]])
if (anyDuplicated(lbls) > 0) {
df[[CONFIG$label_col]] <- make.unique(lbls, sep = "_")
message(sprintf("Label '%s' : %d doublons -> suffixe _N applique",
CONFIG$label_col, sum(duplicated(lbls))))
}
}
aa_n_pool <- nrow(df)
# &e
# &s &APPLY_TRANSFORMS - Phase 3 : pre-process par variable selon _transform CSV
# Si mode CSV-driven, applique log/log1p/arcsin/winsor/yj par variable depuis le
# ddict, cree des colonnes var_log/var_arcsin/... puis SWAP les actives vers ces
# colonnes transformees. Cohérent avec le diag transform de §3 (plus de skip
# silencieux). En mode hardcoded, comportement inchangé.
.ddict_path_safe <- Sys.getenv("TPLJ_DDICT", unset = "")
if (nzchar(.ddict_path_safe) && file.exists(.ddict_path_safe) &&
exists("apply_csv_transforms", mode = "function")) {
tr_res <- apply_csv_transforms(df, .ddict_path_safe)
df <- tr_res$df
if (nrow(tr_res$transformations) > 0) {
# Mapping variable_originale -> variable_transformee
.swap_map <- setNames(tr_res$transformations$output_name,
tr_res$transformations$variable)
.swap_vec <- function(v) ifelse(v %in% names(.swap_map), .swap_map[v], v)
CONFIG$actives <- .swap_vec(CONFIG$actives)
CONFIG$sup_quanti <- .swap_vec(CONFIG$sup_quanti)
# MAJ grid_configs (chaque config peut avoir ses propres actives)
if (!is.null(CONFIG$grid_configs)) {
CONFIG$grid_configs <- lapply(CONFIG$grid_configs, function(cfg) {
cfg$actives <- .swap_vec(cfg$actives)
cfg$sup_quanti <- .swap_vec(cfg$sup_quanti)
cfg
})
}
# FIX 260614 — le transform PAR VARIABLE est déjà appliqué (swap vers var_log/_arcsin).
# On NEUTRALISE le transform GLOBAL hérité de load_grid_from_ddict ("majoritaire"),
# sinon prepare_pca_hcpc re-transforme TOUTES les actives = bug tout-ou-rien
# (1 seule active en _transform=log faisait logger les N actives).
CONFIG$transform <- "none"
if (!is.null(CONFIG$grid_configs))
CONFIG$grid_configs <- lapply(CONFIG$grid_configs, function(cfg) { cfg$transform <- "none"; cfg })
# MAJ DDICT pour que les nouveaux noms aient un label FR
.new_names <- tr_res$transformations$output_name
.orig_names <- tr_res$transformations$variable
for (i in seq_along(.new_names)) {
if (.orig_names[i] %in% names(DDICT)) {
suffix_lbl <- paste0(" (", tr_res$transformations$transform[i], ")")
DDICT[[.new_names[i]]] <- paste0(DDICT[[.orig_names[i]]], suffix_lbl)
}
}
# MAJ DDICT_DF (pivot des rôles) : 1 ligne par variante transformée X_log,
# copiée de la base X (short/medium/long suffixés " (log)") → le pivot affiche
# le label au lieu du nom brut pour les variables transformées.
if (exists("DDICT_DF") && is.data.frame(DDICT_DF) && "variable" %in% names(DDICT_DF)) {
for (i in seq_along(.new_names)) {
if (.new_names[i] %in% DDICT_DF$variable) next
base_row <- DDICT_DF[DDICT_DF$variable == .orig_names[i], , drop = FALSE]
if (nrow(base_row) == 0) next
sfx <- paste0(" (", tr_res$transformations$transform[i], ")")
new_row <- base_row[1, , drop = FALSE]
new_row$variable <- .new_names[i]
for (lc in intersect(c("short", "medium", "long"), names(new_row)))
new_row[[lc]] <- paste0(base_row[[lc]][1], sfx)
DDICT_DF <- rbind(DDICT_DF, new_row)
}
}
message(sprintf("Pre-process transforms : %d variables transformees (actives swap)",
nrow(tr_res$transformations)))
}
}
# &e
# &s &RUN_ACP - Grid ou single config + CACHE RDS (gain ~80% sur re-renders)
# Clef cache basee sur mtime CSV ddict + config selected + nb_actives + nb_configs.
# Si CSV inchange ET meme config → reload du grid stocke en .rds (skip recalcul HCPC O(n²)).
# Quarto --cache-refresh n'invalide PAS notre cache RDS (laisse delete manuel _cache_acp/).
.cache_dir <- file.path(dirname(CONFIG$data_path), "..", "_cache_acp")
dir.create(.cache_dir, showWarnings = FALSE, recursive = TRUE)
.csv_path <- Sys.getenv("TPLJ_DDICT", unset = "")
.csv_mtime <- if (nzchar(.csv_path) && file.exists(.csv_path)) {
format(file.info(.csv_path)$mtime, "%y%m%d_%H%M%S")
} else "nocsv"
.cfg_key <- sprintf("%s_n%d_g%d", CONFIG$selected %||% "single",
length(CONFIG$actives),
if (is.null(CONFIG$grid_configs)) 1L else length(CONFIG$grid_configs))
.cache_file <- file.path(.cache_dir,
sprintf("grid_%s_%s.rds", .csv_mtime, .cfg_key))
if (file.exists(.cache_file)) {
message(sprintf("⚡ Cache ACP HIT : %s (skip recalcul)", basename(.cache_file)))
acp <- readRDS(.cache_file)
} else {
message(sprintf("🔄 Cache MISS, calcul grid en cours..."))
acp <- prepare_pca_hcpc(df, CONFIG)
tryCatch(saveRDS(acp, .cache_file),
error = function(e) warning("Cache RDS save failed : ", e$message))
message(sprintf("💾 Cache SAVED : %s", basename(.cache_file)))
}
list2env(acp, environment()) # expose pca, hcpc, df_pca, grid
# &e
# &s &REORDER_CLUSTERS - relabel clusters par TAILLE décroissante (opt-in)
# CONFIG$cluster_order = "size" → C1 = cluster modal … Cn = le plus petit.
# FactoMineR numérote par position sur Dim1 ; cette option rend l'ordre stable
# et lisible. Réordonne le facteur ET tous les slots nommés (desc.var/axes/ind)
# pour rester cohérent (v-test, parangons). Défaut (NULL) = ordre FactoMineR natif.
if (identical(CONFIG$cluster_order %||% "", "size")) {
.reorder_hcpc_by_size <- function(hcpc) {
tab <- table(hcpc$data.clust$clust)
ord <- names(tab)[order(-as.integer(tab), as.integer(names(tab)))] # old labels, new order
map <- setNames(as.character(seq_along(ord)), ord)
cl <- as.character(hcpc$data.clust$clust)
hcpc$data.clust$clust <- factor(map[cl], levels = as.character(seq_along(ord)))
.relist <- function(lst) {
if (is.null(lst) || length(lst) == 0) return(lst)
keep <- ord[ord %in% names(lst)]
out <- lst[keep]; names(out) <- unname(map[keep]); out
}
for (sl in c("desc.var", "desc.axes")) if (!is.null(hcpc[[sl]])) {
hcpc[[sl]]$quanti <- .relist(hcpc[[sl]]$quanti)
hcpc[[sl]]$category <- .relist(hcpc[[sl]]$category)
}
if (!is.null(hcpc$desc.ind)) {
hcpc$desc.ind$para <- .relist(hcpc$desc.ind$para)
hcpc$desc.ind$dist <- .relist(hcpc$desc.ind$dist)
}
hcpc
}
hcpc <- .reorder_hcpc_by_size(hcpc)
}
# &e
# &s &METRICS - aa_* inline + scores + sil_obj + size_vec + inert
m <- compute_acp_metrics(pca, hcpc, df = df,
size_col = CONFIG$size_col,
label_col = CONFIG$label_col,
vars_mah = CONFIG$actives,
ddict = DDICT)
list2env(m, environment())
# Dim3 AUTO : si < 3 axes retenus (Kaiser), on ne montre pas les plans/cercles 1-3
# (axe non-signal → incohérent). cluster_plane_13 pilote cercle + biplots + clusters.
if (isTRUE(aa_n_kaiser < 3)) CONFIG$cluster_plane_13 <- FALSE
# &e
# &s &LOG_RESULTS - Export JSON pour analyse posterieure (Claude reading)
# Pre-requis : claude_log_init() appele dans le chunk setup
log_step("SETUP",
n_ind = aa_n_ind,
n_actives = aa_n_actives,
config_selected = CONFIG$selected,
k = aa_n_bestk,
transform = paste(CONFIG$transform, collapse = "+"))
# Socle stat global pour Claude — describe + corr + eta² + dump dataset
# Skip "data" si > 500 lignes (dump volumineux), skip "eta2" si pas de sup_quali
log_eda_digest(df,
vars = CONFIG$actives,
quali_vars = CONFIG$sup_quali,
label_col = CONFIG$label_col,
name_prefix = "eda",
max_rows_dump = 500L)
# Grid summary (configs comparees + variable polluante MSA min)
if (!is.null(grid)) {
log_result("grid_summary", as.data.frame(grid$summary),
interpret = sprintf("%d configs testees, config %s retenue (silhouette=%.3f, KMO=%.2f)",
nrow(grid$summary), CONFIG$selected, aa_v_sil, grid$summary$kmo[grid$summary$id == CONFIG$selected]))
# MSA individuels de la config retenue (variable polluante identifiable)
msai_vec <- grid$results[[CONFIG$selected]]$msai
if (!is.null(msai_vec)) {
log_result("msai_individual", as.list(msai_vec),
interpret = sprintf("Variable la plus polluante : %s (MSA=%.2f)",
names(msai_vec)[which.min(msai_vec)], min(msai_vec)))
}
}
# ACP : eigenvalues, contributions, cos² (helper dedie)
log_acp_results(pca)
log_hcpc_results(hcpc, pca)
# Bench k_alt par config (silhouette par k teste) -- silent si aucune config n'a de k_alt
if (!is.null(grid)) log_k_alt_grid(grid$results)
# Profil clusters par variable active (z-score moyen par cluster)
if (exists("typo_profil", mode = "function")) {
profil_df <- typo_profil(hcpc, CONFIG$actives)
log_result("clusters_profil", profil_df,
interpret = sprintf("Profil %d clusters sur %d variables actives",
aa_n_bestk, length(CONFIG$actives)))
}
# Coordonnees individus + cluster + silhouette + mahalanobis
indiv_df <- data.frame(
individu = rownames(pca$ind$coord),
cluster = as.character(hcpc$data.clust$clust),
dim1 = round(pca$ind$coord[, 1], 3),
dim2 = round(pca$ind$coord[, 2], 3),
dim3 = if (ncol(pca$ind$coord) >= 3) round(pca$ind$coord[, 3], 3) else NA_real_,
silhouette = round(scores$silhouette, 3),
voisin = scores$voisin,
mahalanobis = round(scores$mahalanobis, 2),
stringsAsFactors = FALSE
)
log_result("indiv_clusters", indiv_df,
interpret = sprintf("%d individus : coords ACP + cluster + silhouette + mahalanobis",
nrow(indiv_df)))
# Top atypiques (mahalanobis)
top_aty <- indiv_df[order(-indiv_df$mahalanobis), ][1:min(10, nrow(indiv_df)), ]
log_result("top_atypiques", top_aty,
interpret = sprintf("Top %d individus atypiques (mahalanobis) : %s",
nrow(top_aty), paste(head(top_aty$individu, 5), collapse = ", ")))
# Mal classes (silhouette < 0)
mal_classes <- indiv_df[indiv_df$silhouette < 0, ]
if (nrow(mal_classes) > 0) {
log_result("mal_classes", mal_classes,
interpret = sprintf("%d individus mal classes (silhouette<0, %.1f%%)",
nrow(mal_classes), 100 * nrow(mal_classes) / nrow(indiv_df)))
}
# Parangons par cluster
if (exists("typo_parangons", mode = "function")) {
para_df <- typo_parangons(hcpc, n = 5)
log_result("parangons", para_df,
interpret = sprintf("Parangons %d clusters (5 plus proches du barycentre)",
aa_n_bestk))
}
# Inertie + qualite globale
if (!is.null(inert)) {
log_result("inertie", inert,
interpret = sprintf("Inertie inter/totale apres consolidation: %.3f",
ifelse(!is.na(inert$inertie_inter_apres), inert$inertie_inter_apres, 0)))
}
# Sauvegarde finale du JSON
claude_log_save()
# &e
# &e (FIN CALC_ALL_aaMAIN)
```
## 1 — Cadrage & méthode
Ce notebook d'analyse explore un jeu de **`r aa_n_pool` individus** décrits par **`r aa_n_actives` variables actives** (indicateurs continus), complétées par des variables **supplémentaires** projetées *a posteriori*. Deux questions le guident :
- ces variables, qui souvent se recoupent, se ramènent-elles à **quelques grandes dimensions de fond** ?
- peut-on alors regrouper les individus en **clusters aux profils homogènes** ?
Pour répondre aux deux d'un même geste, il mobilise la méthode **HCPC**[^hcpc] (ou Classification Hiérarchique sur Composantes Principales), qui procède en trois temps.
- D'abord **réduire** : l'**ACP** ramène les `r aa_n_actives` variables à une poignée d'axes synthétiques indépendants — **`r aa_n_kaiser`** passent le seuil de Kaiser, concentrant **`r aa_v_var12` %** de l'information sur le plan principal. Ce sont eux qui répondent à la première question.
- Ensuite **regrouper** : sur ces axes, un **arbre de classification ou hierarchique** (CAH classification ascendante hiérarchique) est construit puis coupé là où la séparation entre familles devient la plus nette. Il dessine les clusters et indique combien en retenir, c'est le dendrogramme du §6.
- Enfin **affiner** : un dernier passage réaffecte les individus à cheval entre deux familles vers la plus proche, ce qui stabilise la partition (consolidation des classes via un partitionnement avec la méthode des k-means).
::: {.callout-note collapse="true" appearance="minimal"}
## Pourquoi HCPC plutôt qu'un k-means ?
Selon ce à quoi on le compare, l'apport de HCPC se déplace :
- **vs un k-means brut** (sur variables brutes) — l'ACP en amont **décorrèle et débruite** : deux variables corrélées (ex. `0,80`) ne pèsent plus double dans les distances, et les axes de pur bruit sont écartés. Partitions nettement plus stables.
- **vs un k-means déjà mené sur composantes** — l'apport propre devient l'**arbre**. Il *suggère* le nombre de clusters par le gain d'inertie, du plus général au plus fin, là où le k-means l'exige d'avance ; il permet aussi de *juger un `k` imposé* : lorsqu'on force `k = 6` pour un besoin opérationnel, le dendrogramme montre si la coupure épouse la structure ou la tranche. Enfin, sa coupe fournit un **point de départ déterministe** à la consolidation, ce qui évite l'instabilité d'un k-means à initialisation aléatoire.
- **vs une ACP et une CAH menées séparément** — l'apport est l'**intégration** : un pipeline reproductible qui enchaîne réduction, arbre, consolidation et description automatique des classes.
Dans tous les cas, la typologie reste **nommable** : chaque cluster se lit par les variables et les axes qui le portent.
*Comparaison détaillée des techniques voisines (CAH brute, k-means direct, DBSCAN, FAMD, MDS, t-SNE) : cf note méthodo dédiée.*
:::
Plusieurs choix méthodologiques se posent en amont : quelles variables retenir comme actives, faut-il les transformer (log, winsor), faut-il projeter certaines variables comme supplémentaires illustratives (quanti et quali). L'analyse s'appyuie sur une **démarche en deux temps**, pilotée par le module construit `acp_grid`. Dans un premier temps, il met **`r ifelse(is.null(grid), 1L, nrow(grid$summary))` configurations candidates** en concurrence et les compare sur les mêmes critères — qualité de la factorisation, redondance résiduelle, netteté de la typologie — pour faire émerger celle qui sort le mieux sur l'ensemble des métriques. Dans un second temps seulement, la configuration retenue (**`r CONFIG$selected`**) est déroulée en détail. Cette comparaison systématique évite de figer un cadrage arbitraire et rend le choix traçable.
**Comment lire la suite** — chaque section répond à une question, et les deux dernières débouchent sur la lecture métier :
- **§3 — Données : quel pool, quelles variables ?** Périmètre des individus retenus et revue des variables candidates, avec leurs statistiques de base et un diagnostic de distribution.
- **§4 — Grille : quelle configuration retenir ?** Les candidates mises en regard les unes des autres, puis le choix justifié sur les métriques.
- **§5 — ACP : quelles dimensions structurent les variables ?** Combien d'axes méritent une interprétation, et ce que chacun raconte au plan métier.
- **§6 — Typologie : quelles familles d'individus ?** Le nombre de clusters (`r aa_n_bestk` ici), l'arbre de classification, la fiabilité du classement de chaque individu, enfin le portrait de chaque famille et sa lecture métier.
- **§7 — Qualité : la typologie tient-elle ?** Familles correctement dimensionnées, classement net, séparation réelle entre clusters.
::: {.callout-tip collapse="true" appearance="minimal"}
## REPÈRES MÉTHODO : sur quoi repose ce pipeline, et où sont ses limites
**Hypothèses sous-jacentes** : Le pipeline suppose des variables **continues**. Une variable ordinale se code en numérique tant que son ordre reste stable ; à défaut, il faut basculer vers une analyse pensée pour les variables qualitatives[^famd]. Il suppose aussi des **relations linéaires** entre variables, puisque l'ACP ne capte que ce type de lien, et l'**absence de variable cible** parmi les actives, sous peine de fausser les axes. Les variables sont systématiquement centrées et réduites : sans cela, celles qui s'expriment dans de grandes unités écraseraient les autres.
**Triangulation des diagnostics** : Aucun indicateur pris seul ne suffit à juger la qualité du résultat. Pour l'ACP, on croise l'adéquation de la matrice de corrélation (KMO), le test de sphéricité de Bartlett, le critère de Kaiser et la *parallel analysis* de Horn pour arrêter le nombre d'axes, enfin la part de variance cumulée. Pour la typologie, on regarde la silhouette, la part de variance captée entre les familles, des indices de séparation comme Calinski-Harabasz, et la stabilité de la partition par rééchantillonnage.
Au-delà de quelques milliers d'individus, l'arbre complet devient trop coûteux en mémoire, car la matrice des distances croît avec le carré du nombre d'individus. Le pipeline construit alors d'abord une partition grossière en quelques centaines de groupes, sur laquelle l'arbre est bâti ; chaque individu d'origine reste rattaché à sa famille finale, si bien que la carte reste exhaustive.
**Limites à connaître** :
- (1) une silhouette <0,25 ne signale pas un échec — sur données territoriales/sciences sociales les profils forment un *continuum*, pas des catégories tranchées ; (2) un cluster <5 % des individus est fragile (à fusionner ou expliquer) ; (3) corrélation v.test ≠ causalité — si des variables sensibles (origine, genre, statut) figurent en actives, disclaimer fairness obligatoire et envisager une régression équitable (`fairml::frrm()`).
:::
[^hcpc]: *Hierarchical Clustering on Principal Components* — une classification menée non pas sur les variables brutes mais sur les axes d'une analyse en composantes principales.
[^famd]: Analyse factorielle de données mixtes, ou analyse des correspondances multiples lorsque toutes les variables sont qualitatives.
## 2 — Bilan : que retient-on de l'analyse ?
Ce bilan condense l'analyse en une page : **combien** de profils se dégagent, **comment** les nommer, **où** ils se situent dans le plan factoriel, et **ce qu'il faut en retenir**. Le détail — variables discriminantes, qualité de la partition, validation externe — suit dans les parties 3 à 8.
<!-- Variables dynamiques MÉTIER (dim_names/comments + cluster_names/comments).
Définit dim_names/cluster_names utilisés par le bilan ET §5/§7 ; édité dans _ccom-acp-vardyn.qmd. -->
{{< include _ccom-acp-vardyn.qmd >}}
```{r}
#| label: acp-bilan-snapshot
#| include: false
#| cache: false
# SNAPSHOT de l'INTERFACE bilan -> réutilisable par un rapport de synthèse (découplé,
# zéro recompute ACP). Sauvé ICI car dim_names (vardyn) + aa_*/indiv_df (calc-all) sont dispo.
acp_bilan_snapshot <- c(
list(pca = pca, hcpc = hcpc, indiv_df = indiv_df, DDICT = DDICT, CONFIG = CONFIG,
dim_names = dim_names, dim_comments = dim_comments,
cluster_names = cluster_names, cluster_comments = cluster_comments),
mget(ls(pattern = "^aa_")))
if (!dir.exists("data/rsl")) dir.create("data/rsl", recursive = TRUE)
saveRDS(acp_bilan_snapshot, "data/rsl/acp-bilan-snapshot.rds")
```
{{< include _00-bilanacp.qmd >}}
## 3 — Données : quel pool, quelles variables ?
**`r aa_n_pool` individus** dans le pool d'analyse. Le dictionnaire ci-dessous présente les **`r length(DDICT)` variables candidates** avec leurs stats descriptives (P10/médiane/P90/SD) et un **diagnostic transform** (skewness/kurtosis + reco log/arcsin/winsor/yj). Triées par |skewness| décroissant. Le rôle de chaque variable selon la config (active / supplémentaire) est traité §4.
```{r}
#| label: rt-ddict-transform
#| cache: false
# Vue unifiée : stats descriptives + diagnostic transform sur TOUTES les vars
# utilisees dans n'importe quelle config du grid (pas juste config retenue).
# Couvre tableau pivot configs vs diag transform : meme liste sans decalage.
.collect_all_vars <- function() {
v_set <- union(CONFIG$actives, CONFIG$sup_quanti)
if (!is.null(CONFIG$grid_configs)) {
for (cfg in CONFIG$grid_configs) {
v_set <- union(v_set, cfg$actives)
v_set <- union(v_set, cfg$sup_quanti)
}
}
v_set
}
.vars_focus <- intersect(.collect_all_vars(), names(df))
.vars_focus <- .vars_focus[sapply(.vars_focus, function(v) is.numeric(df[[v]]))]
if (length(.vars_focus) > 0) {
rt_eda_transform_hints(df, vars = .vars_focus,
labels = DDICT[.vars_focus],
height = 400, sort_by_skew = TRUE,
include_quantiles = TRUE)
}
```
::: {.note-lecture}
**Règles diagnostic transform** (Hair et al. 2010) : 🔴 `log` ou `yj` si |skew| > 2 (impératif, `yj` si valeurs négatives) · `log1p` si min=0 (gère les zéros) · `arcsin` pour proportions/pct [0,1] ou [0,100] avec |skew| > 2 (Fisher angular transform) · 🟠 `log?` / `yj?` si 1 < |skew| ≤ 2 (recommandé) · 🟡 `winsor` si kurtosis > 7 (queues lourdes) · ⚪ `—` distribution acceptable.
:::
## 4 — Grille : quelle config retenir ?
Plusieurs configurations sont candidates : choix des variables actives, transformations (log, winsor), nombre de clusters. Cette section les **compare côte à côte** pour justifier la config retenue.
Avant de comparer les configs, **diagnostic préalable** : Bartlett + ratio n/p vérifient sur l'union des variables actives que le jeu de données autorise une analyse factorielle.
```{r}
#| label: diag-preal
#| eval: !expr CONFIG$run_grid
#| cache: false
#| results: asis
diag <- compute_data_diagnostic(df, CONFIG$grid_configs)
render_data_diagnostic(diag)
```
Le tableau ci-dessous compare les **`r length(CONFIG$grid_configs)` configurations candidates** sur **Setup** (vars actives, transformation) · **Diagnostic ACP** (Var Dim1/2/3 % compact, MSA min, multicolinéarité, Kaiser/Horn, nettoyage si applicable) · **Qualité HCPC** (k clusters, silhouette, équilibre). Gradient cellule V12 : 🟢 cible atteinte · 🟡 zone intermédiaire · 🟠 sous le seuil acceptable · 🔴 hors limites. ✅ config retenue, ❌ config écartée.
::: {.callout-tip collapse="true" appearance="minimal"}
## Logique du pipeline `acp_grid` — sortir de la boîte noire
L'approche repose sur un **helper interne `run_acp_grid()`** qui automatise la comparaison de configurations candidates : pour chaque entrée de `GRID_CONFIGS` (variantes ACP + HCPC), il calcule l'ACP complète + diagnostics (KMO, MSA min, multicolinéarité, parallel analysis de Horn) + le clustering HCPC (silhouette, équilibre, k auto-sélectionnable). Les résultats sont stockés dans `grid$summary` (tibble de comparaison) et `grid$results` (objets ACP/HCPC par config).
**4 étapes ci-dessous** :
1. **Vue croisée des configurations testées** — quelles variables actives / supplémentaires / retirées dans chaque candidate
2. **Repères méthodo : seuils et définitions** — sur quels critères on juge (KMO, silhouette, etc.) avec gradient 4 zones
3. **Diagnostic préalable + tableau récap chiffré** — Bartlett + indicateurs agrégés par config
4. **Synthèses rédigées par config + MSA individuels** — narratif par candidate + variables polluantes
La **config retenue** (`CONFIG$selected`) est marquée ✅ dans le tableau récap et dépliée par défaut dans les synthèses.
:::
### Vue croisée des configurations testées : quelles variables retient-on ?
Le tableau ci-dessous croise **chaque variable** (en ligne) avec **chaque config** (en colonne)Rappel des rôles :
- **active** (`qact`, bleu cyan) = participe au calcul des axes principaux ;
- **supplémentaire quanti** (`qsup`, orange) = projetée *a posteriori* sur les axes sans les influencer (cible, variable de contrôle géographique, indicateur dérivé) ;
- **supplémentaire quali** (`qlsup`, gris) = modalités positionnées comme parangons illustratifs (variable nominale, segment métier).
Header bleu sur la config retenue.
```{r}
#| label: rt-config-pivot
#| eval: !expr CONFIG$run_grid && exists("build_config_pivot", mode = "function")
#| cache: false
# Ddict pour pivot : DDICT_DF (data.frame) si dispo (avec theme, unit optionnels),
# sinon DDICT (named vector) avec fallback nom = label, sinon NULL (helper fallback complet).
ddict_for_pivot <- if (exists("DDICT_DF")) {
DDICT_DF
} else if (exists("DDICT")) {
data.frame(variable = names(DDICT), short = unname(DDICT),
theme = "—", stringsAsFactors = FALSE)
} else {
NULL
}
unit_col_auto <- if (!is.null(ddict_for_pivot) && "unit" %in% names(ddict_for_pivot)) "unit" else NULL
pivot_df <- build_config_pivot(CONFIG$grid_configs, ddict_for_pivot,
theme_col = "theme", var_col = "variable", label_col = "short",
unit_col = unit_col_auto)
rt_config_pivot(pivot_df, selected = CONFIG$selected,
show_var = TRUE, unit_col = "unit", legend = TRUE)
```
Le tableau ci-dessous met en regard les `r if (!is.null(grid)) nrow(grid$summary) else 1L` configurations testées (titre et regroupements de colonnes intégrés au tableau). Le gradient des cellules donne une lecture rapide des seuils par métrique — détail dans l'encadré méthodologique sous le tableau.
```{r}
#| label: rt-grid-summary
#| eval: !expr CONFIG$run_grid
#| cache: false
#| column: page
rt_acp_summary_v2(grid$summary, selected = CONFIG$selected)
```
::: {.note-lecture}
**Gradient cellules V12** — 4 selon distance à la cible : 🟢 **vert** (cible atteinte) · 🟡 **jaune** (entre cible et acceptable) · 🟠 **orange** (sous le seuil acceptable) · 🔴 **rouge** (hors limites). Direction sous chaque label (cible ≥ X pour MSA/Silh./Var ; cible < X pour max |r|/Paires/Ratio). **Équilibre clusters** : `% de chaque cluster trié décroissant · r=ratio max/min` (gradient sur le ratio). **Note** : survol cellule = texte complet. **Nettoyage** : `−N (var, +X)` si pipeline a retiré N vars.
:::
```{r}
#| label: warn-cor-pairs
#| eval: !expr CONFIG$run_grid
#| cache: false
#| results: asis
# Callout avertissement REPLIÉ : identifie les paires |r|>0.85 par config
# (la colonne "Paires |r|>.85" du tableau ne donne que le compte). Silent si aucune.
render_acp_cor_pairs_warning(grid$summary, labels = DDICT)
```
```{r}
#| label: ref-methodo-acp
#| eval: !expr CONFIG$run_grid
#| cache: false
#| results: asis
# Callout pattern cgris-2imb : 3 metriques cles visibles + 7 repliees
# Source unique : mutils/ddict-gls-DML-KPI-metrics.csv
render_acp_metrics_glossary()
```
```{r}
#| label: k-alt-bench
#| eval: !expr CONFIG$run_grid
#| cache: false
#| results: asis
# Bench k_alt INTERNE par config (sélection auto-silhouette du k). Silent si aucune config n'a k_alt.
render_k_alt_summary(grid$results, selected = CONFIG$selected)
```
Chaque config est resumée ci dessous commençant par la celle retenue; représentation 2D (var 12), qualité ACP, redondance, typologie, **Nettoyage redondance** si le pipeline a retiré des variables fortement corrélées.
```{r}
#| label: narratives-grid
#| eval: !expr CONFIG$run_grid
#| cache: false
#| results: asis
render_config_narratives(
grid$summary,
selected = CONFIG$selected,
context = getOption("acp.context", "territorial"),
grid_results = grid$results)
```
Le **MSA individuel** par variable de la config retenue ci-dessous identifie celles qui sont mal intégrées au groupe (MSA < 0,5 = candidate au retrait).
```{r}
#| label: msa-individuals
#| eval: !expr CONFIG$run_grid
#| cache: false
#| results: asis
render_msa_individuals(grid$results, selected = CONFIG$selected,
context = getOption("acp.context", "territorial"))
```
## 5 — ACP : quelles dimensions structurent les variables ?
L'ACP réduit les `r aa_n_actives` variables actives à un petit nombre d'axes synthétiques (composantes principales) qui capturent l'essentiel de l'information. La lecture se fait en deux temps : (1) **éboulis + diagnostics** déterminent *combien* d'axes méritent d'être interprétés ; (2) **biplots** disent *quoi* chacun raconte — quelles variables le structurent et quels individus s'opposent dans ce plan.
### Éboulis : combien d'axes retenir ?
L'**éboulis** (scree plot) classe les axes par variance expliquée décroissante. Deux critères académiques décident combien retenir :
- **Kaiser** (1960) — axes avec valeur propre λ > 1. Un axe doit expliquer plus qu'une variable seule pour mériter d'être interprété. **Sur ce jeu : `r aa_n_kaiser` axes**.
- **Parallel analysis de Horn** (1965) — compare les eigenvalues observés à ceux simulés sur des datasets aléatoires de même dimension. Retenir uniquement les axes où λ_obs > λ_sim(95ᵉ percentile). **Méthode plus parcimonieuse que Kaiser**, particulièrement à p > 10 où Kaiser sur-extrait systématiquement (Glorfeld 1995 ; Zwick & Velicer 1986).
::: {.callout-tip collapse="true" appearance="minimal"}
## REPÈRES MÉTHODO : Kaiser vs Horn, et seuils variance
**Quand Horn et Kaiser divergent** : sur peu de variables (p < 10) ils concordent souvent. Sur p > 20, Kaiser sur-extrait — un dataset social typique à p ≈ 100 vars peut donner Kaiser ≈ 20 axes vs Horn 5-8. Dans ce cas **toujours suivre Horn**.
**Quand les deux concordent** : signal de qualité du jeu de données (pas de sur-dispersion, le pool actif est bien centré sur l'information utile).
**Seuils variance cumulée** (lecture pragmatique) :
- Var 1-2 ≥ 50 % = plan factoriel principal lisible
- Var 1-2 < 40 % = la 2D ne capture qu'une fraction, regarder l'axe 3 + biplot 1-3
- Var 1-3 ≥ 70 % = analyse robuste sur 3 axes
- Var 1-3 < 60 % = sur-dispersion, vérifier si certains axes capturent du bruit (cf. Horn)
**Coude visuel** : rupture nette sur l'éboulis entre axes informatifs et bruit résiduel. Cohérent avec Kaiser le plus souvent, parfois plus restrictif (= Horn).
:::
*Sur ce jeu : `r aa_n_kaiser` axes passent Kaiser, **Horn confirme `r aa_n_horn`**. Les 2 premiers cumulent `r aa_v_var12` % de variance ; les 3 premiers `r aa_v_var13` %.*
```{r}
#| label: plt-screeplot
#| fig-width: 10
#| fig-height: 5
# Note : add_horn = TRUE pour overlay courbe sim Horn 95e pct (couteux O(iter*n*p),
# auto-skip si n>1500). Horn est deja loggue dans le JSON via log_acp_results() : la
# valeur n_horn s'affiche dans la synthese ACP en bas de §5.
plot_screeplot(pca, add_horn = FALSE)
```
### Cercle des corrélations : comment les variables se positionnent ?
Le **cercle des corrélations** projette les variables seules (sans les individus) sur les plans Dim1×Dim2 et Dim1×Dim3. Chaque flèche est une variable : sa **longueur** mesure la qualité de projection sur le plan (proche du cercle unité = bien représentée), l'**angle** entre deux flèches approxime leur corrélation. Les variables **actives** (colorées par cos²) construisent les axes ; les **supplémentaires** — quanti en pointillé bleu, modalités qualitatives en losange violet — sont projetées *a posteriori* sans peser sur le calcul.
::: {.callout-tip collapse="true" appearance="minimal"}
## REPÈRES MÉTHODO : géométrie du cercle et lecture des angles
**Longueur de flèche** = norme de la projection sur ce plan. Flèche longue (proche du cercle) = variable bien représentée. Flèche courte = se cache dans une autre dimension.
**Angle entre 2 flèches** : 0° = corrélation forte positive ; 90° = indépendance (orthogonales) ; 180° = corrélation forte négative.
**Chaque axe se lit comme un gradient** : les variables contributrices nomment le gradient (ex : Dim1 = « décrochage scolaire vs revenus élevés », Dim2 = « jeunes vs vieillissants »).
**Variables supplémentaires** (flèches pointillées / losanges) : projetées *a posteriori*, n'influencent pas le calcul des axes. Utiles pour valider la cohérence avec une cible ou une variable de contrôle (Lat/Long, taille de pop).
:::
```{r}
#| label: plt-circle-corr
#| cache: false
#| out-width: "100%"
#| fig-width: 11
#| fig-height: 7
#| echo: false
# Cercle des correlations : 2 plans COTE A COTE (ncol=2) en column-page-right,
# taille réduite (chacun ~6 de large). Conventions alignées sur plot_biplot_dual :
# actives = flèches cos², sup quanti = bleu clair #5a9ec4 pointillé italique,
# sup quali = carré violet.
# Dim3 (plan 1-3) affiché SEULEMENT si ≥ 3 axes retenus (cluster_plane_13, auto).
# Sinon (ncp = 2) : cercle 1-2 uniquement — évite l'incohérence d'un axe non retenu.
.axes_pairs <- if (isTRUE(CONFIG$cluster_plane_13)) list(c(1, 2), c(1, 3)) else list(c(1, 2))
plot_circle_corr_dual(pca,
axes_pairs = .axes_pairs,
labels = DDICT,
show_sup = TRUE,
var_filter = 0.0,
max_quali_modal = 8,
ncol = length(.axes_pairs), label_scale = 1.3)
```
### Biplot dual — Axes 1-2 (`r aa_v_var12` % variance)
Le **biplot dual** ajoute les **individus** (points) aux variables, sur deux panneaux complémentaires : **cos²** (qualité de représentation, rouge = bien projetée) et **contrib** (structuration des axes). L'apport par rapport au cercle ci-dessus est la **superposition des individus** : un point proche d'une flèche a une valeur élevée sur cette variable — c'est ce qui permet de **nommer les clusters** par les variables des flèches voisines.
::: {.callout-note appearance="minimal"}
## Lecture analytique du plan 1-2
```{r}
#| label: biplot-12-lecture
#| echo: false
#| results: asis
# MÉTIER (dim_comments axes 1 & 2) en tête [.minj = repérage injecté], puis AUTO en dessous.
cat("::: {.minj}\n")
cat(sprintf("**Axe 1 — %s.** %s\n\n", dim_names[1], dim_comments[1]))
cat(sprintf("**Axe 2 — %s.** %s\n\n", dim_names[2], dim_comments[2]))
cat(":::\n\n")
# Lecture AUTO des pôles : top contrib Dim1/Dim2, noms TRADUITS via DDICT. ==marker== Pandoc.
top1 <- head(sort(pca$var$contrib[, 1], decreasing = TRUE), 4)
top2 <- head(sort(pca$var$contrib[, 2], decreasing = TRUE), 4)
sign1 <- sign(pca$var$coord[names(top1), 1])
sign2 <- sign(pca$var$coord[names(top2), 2])
pos1 <- names(top1)[sign1 > 0]; neg1 <- names(top1)[sign1 < 0]
pos2 <- names(top2)[sign2 > 0]; neg2 <- names(top2)[sign2 < 0]
# Traduction code R -> label FR via DDICT (fallback code si absent)
.lblv <- function(vs) vapply(vs, function(v) {
l <- if (!is.null(DDICT) && v %in% names(DDICT)) DDICT[[v]] else NA_character_
if (is.null(l) || is.na(l) || !nzchar(l)) v else l }, character(1))
.wrap_mark <- function(v) if (length(v) == 0) "—" else paste(sprintf("*%s*", .lblv(v)), collapse = ", ")
cat("*Pôles automatiques (variables aux plus fortes contributions) :*\n\n")
cat(sprintf("- **Axe 1** (%.1f %%) — à droite %s · à gauche %s\n",
aa_v_dim1, .wrap_mark(pos1), .wrap_mark(neg1)))
cat(sprintf("- **Axe 2** (%.1f %%) — pôle haut %s · pôle bas %s\n",
aa_v_dim2, .wrap_mark(pos2), .wrap_mark(neg2)))
```
:::
::: {.column-screen-inset}
```{r}
#| label: plt-biplot-dual-12
#| cache: false
#| fig-width: 16
#| fig-height: !expr biplot_dual_height(pca, c(1,2))
plot_biplot_dual(pca, axes = c(1, 2), top_n = CONFIG$top_n,
show_sup = TRUE, show_cor = TRUE, size_col_data = size_vec,
labels = DDICT,
interactive = FALSE, title = FALSE)
```
:::
### Biplot dual — Axes 1-3 (`r aa_v_plan13` % du plan)
Le plan **Dim1 × Dim3** révèle une dimension complémentaire que le plan principal ne montre pas. Souvent l'axe 3 oppose des profils que les 2 premiers axes ne séparent pas clairement (sous-typologie, profil atypique). À examiner pour valider qu'un cluster reste cohérent sur les 3 axes.
::: {.callout-note appearance="minimal"}
## Lecture analytique du plan 1-3
```{r}
#| label: biplot-13-lecture
#| echo: false
#| results: asis
# MÉTIER (dim_comments axe 3) en tête [.minj], puis lecture AUTO du pôle en dessous.
cat("::: {.minj}\n")
cat(sprintf("**Axe 3 — %s.** %s\n\n", dim_names[3], dim_comments[3]))
cat(":::\n\n")
# Lecture AUTO du pôle Dim3. Noms TRADUITS via DDICT.
top3 <- head(sort(pca$var$contrib[, 3], decreasing = TRUE), 4)
sign3 <- sign(pca$var$coord[names(top3), 3])
pos3 <- names(top3)[sign3 > 0]; neg3 <- names(top3)[sign3 < 0]
.lblv <- function(vs) vapply(vs, function(v) {
l <- if (!is.null(DDICT) && v %in% names(DDICT)) DDICT[[v]] else NA_character_
if (is.null(l) || is.na(l) || !nzchar(l)) v else l }, character(1))
.wrap_mark <- function(v) if (length(v) == 0) "—" else paste(sprintf("*%s*", .lblv(v)), collapse = ", ")
cat("*Pôle automatique (variables aux plus fortes contributions) :*\n\n")
cat(sprintf("- **Axe 3** (%.1f %%) — pôle haut %s · pôle bas %s\n\n",
aa_v_dim3, .wrap_mark(pos3), .wrap_mark(neg3)))
cat("*Cohérence 1-3* : un cluster bien séparé en 1-2 **et** compact en 1-3 = caractérisation robuste sur 3 axes ; éclaté en 1-3 = hétérogénéité interne.\n\n")
```
:::
::: {.column-screen-inset}
```{r}
#| label: plt-biplot-dual-13
#| eval: !expr isTRUE(CONFIG$cluster_plane_13)
#| cache: false
#| fig-width: 16
#| fig-height: !expr biplot_dual_height(pca, c(1,3))
plot_biplot_dual(pca, axes = c(1, 3), top_n = CONFIG$top_n,
show_sup = TRUE, show_cor = TRUE, size_col_data = size_vec,
labels = DDICT,
interactive = FALSE, title = FALSE)
```
:::
### Synthèse ACP : dimensions retenues + lecture thématique
```{r}
#| label: synthese-acp-fused
#| echo: false
#| results: asis
# Bloc UNIQUE et VISIBLE : synthèse factuelle (encadre-insight) + lecture thématique
# MANUELLE des axes (dim_comments de _ccom-acp-vardyn). Plus de callout repliable,
# plus de bloc auto dupliqué (render_acp_dim_themes + "À interpréter comme un gradient"
# retirés — 260615).
cat("::: {.encadre-insight}\n")
cat(sprintf("[**SYNTHÈSE ACP — %d axes retenus, %.1f %% de variance cumulée**]{.encadre-insight-titre}\n\n",
min(aa_n_kaiser, aa_n_horn), aa_v_var13))
cat(sprintf("- **Concordance Kaiser/Horn** — %s. %s\n",
if (aa_n_kaiser == aa_n_horn) sprintf("Kaiser et Horn convergent sur **%d axes**", aa_n_kaiser)
else sprintf("Kaiser propose **%d axes**, Horn (plus parcimonieux) ramène à **%d**", aa_n_kaiser, aa_n_horn),
if (aa_n_kaiser == aa_n_horn) "Signal de qualité — le jeu de données ne sur-disperse pas."
else "Suivre Horn (Glorfeld 1995) — Kaiser sur-extrait à p>10."))
cat(sprintf("- **Plan principal Dim1×Dim2** capte %.1f %% de variance. %s\n",
aa_v_var12,
if (aa_v_var12 >= 50) "Plan factoriel principal lisible — l'interprétation 2D est fiable."
else if (aa_v_var12 >= 40) "Plan principal modéré — vérifier la cohérence sur Dim1×Dim3."
else "Plan principal faible — la 2D ne capture qu'une fraction, regarder absolument l'axe 3."))
cat(sprintf("- **Ncp recommandé pour HCPC** = %d (médiane Kaiser/Horn).\n",
as.integer(stats::median(c(aa_n_kaiser, aa_n_horn), na.rm = TRUE))))
# Lecture thématique MANUELLE des dimensions (qualification analyste, éditée dans
# _ccom-acp-vardyn) — directement dans le même encadré, visible (plus de "déplier").
cat("\n**Lecture thématique des dimensions** (qualification analyste) :\n\n")
for (.i in seq_along(dim_names))
cat(sprintf("- **Axe %d — %s.** %s\n", .i, dim_names[.i], dim_comments[.i]))
cat(":::\n\n")
```
## 6 — HCPC : choix et visualisation des clusters
La classification ascendante hiérarchique (Ward) sur les coordonnées ACP
identifie `r aa_n_bestk` clusters de profils homogènes. Cette partie **choisit** le
nombre de clusters (éboulis, dendrogramme) et **vérifie la qualité** de la partition
(silhouette), puis **positionne** les clusters dans le plan factoriel. La
**caractérisation** (par quelles variables nommer chaque cluster) fait l'objet de
la partie 7.
::: {.callout-tip collapse="true" appearance="minimal"}
## REPÈRES MÉTHODO : qualité d'un clustering et lecture des profils
**Silhouette (Kaufman 1990)** : pour chaque individu, mesure +1 (dans son cluster) à -1 (mal classé). Moyenne **≥ 0,50 = forte** structure ; 0,25–0,50 = raisonnable ; < 0,25 = pas de structure substantielle. *Une silhouette de 0,21 ne veut pas dire « pas de typologie » mais que les clusters se chevauchent dans le plan factoriel — l'interpréter prudemment.*
**Mal classés** (silhouette < 0) : individus dont le cluster voisin est plus proche que le sien. > 10 % = clusters mal séparés ; < 5 % = bonne cohérence.
**Mahalanobis** : distance multivariée d'un individu au profil moyen de son cluster, en tenant compte des corrélations. Moyenne attendue ≈ nombre d'actives. Valeur élevée = individu atypique dans son groupe.
**v-test** : signe et amplitude de l'écart entre la moyenne du cluster et la moyenne globale, exprimé en écarts-types. **|v| > 1,96 = significatif (p < 5 %)** ; |v| > 3 = très significatif (p < 0,1 %). Sert à nommer les clusters par les variables qui les distinguent le plus.
**Parangons** : individus les plus représentatifs de chaque cluster (au plus proche du barycentre). Utiles pour l'interprétation narrative.
:::
### Choix de k : éboulis des gains d'inertie
L'**éboulis des gains d'inertie** affiche le saut d'inertie inter-classes à chaque coupure de l'arbre. Un **saut prononcé** marque la « bonne » coupure : passer de k à k+1 clusters apporte un gain marginal seulement si on est près d'un palier. Le k retenu se justifie quand le gain devient faible (palier visible). **k = `r aa_n_bestk` retenu** ici — la barre bleue dans le graph.
```{r}
#| label: plt-elbow-lollipop
#| fig-width: 13
#| fig-height: 4.5
#| out-width: "100%"
#| echo: false
# Deux vues du MÊME saut d'inertie, CÔTE À CÔTE :
# gauche = gain d'inertie par k (barre) · droite = lollipop top 10 fusions.
# --- Gauche : éboulis gain d'inertie par k ---
.heights <- rev(sort(hcpc$call$t$tree$height))
.n_show <- min(10, length(.heights) - 1)
.gains <- -diff(.heights[1:(.n_show + 1)])
.df_elb <- data.frame(k = 2:(.n_show + 1), gain = .gains)
p_elbow <- ggplot(.df_elb, aes(x = factor(k), y = gain)) +
geom_col(fill = ifelse(.df_elb$k == aa_n_bestk, "#1696d2", "gray70"), width = 0.6) +
geom_text(aes(label = round(gain, 1)), vjust = -0.5, size = 3) +
labs(title = sprintf("Gain d'inertie inter-classes — k=%d retenu", aa_n_bestk),
x = "Nombre de clusters", y = "Gain") +
theme_minimal(base_size = 11) +
theme(plot.title = element_text(size = 11, face = "bold"))
# --- Droite : lollipop top 10 fusions (hauteurs classées : saut max auto vs k retenu) ---
heights <- rev(sort(hcpc$call$t$tree$height))
top_h <- head(heights, 10)
gaps <- -diff(top_h)
k_auto <- which.max(gaps) + 1L
df_top <- data.frame(rank = seq_along(top_h), height = top_h)
df_top$pt_color <- ifelse(df_top$rank == k_auto, "#ec008b", "#1696d2")
p_lol <- ggplot(df_top, aes(x = rank, y = height)) +
geom_segment(aes(xend = rank, yend = 0, color = pt_color), linewidth = 0.8) +
geom_point(aes(color = pt_color), size = 2.8) +
scale_color_identity()
h_retenu <- top_h[aa_n_bestk]; h_auto <- top_h[k_auto]
same_k <- identical(as.integer(aa_n_bestk), as.integer(k_auto))
if (same_k) {
p_lol <- p_lol +
geom_hline(yintercept = h_retenu, linetype = "dashed", color = "#ca5800", linewidth = 0.8) +
annotate("text", x = 9.5, y = h_retenu * 1.08,
label = sprintf("k=%d retenu (= saut max auto)", aa_n_bestk),
color = "#ca5800", fontface = "bold", size = 3.3, hjust = 1)
} else {
p_lol <- p_lol +
geom_hline(yintercept = h_retenu, linetype = "dashed", color = "#ca5800", linewidth = 0.8) +
geom_hline(yintercept = h_auto, linetype = "dotted", color = "#ec008b", linewidth = 0.8) +
annotate("text", x = 9.5, y = h_retenu * 1.08, label = sprintf("k=%d retenu", aa_n_bestk),
color = "#ca5800", fontface = "bold", size = 3.3, hjust = 1) +
annotate("text", x = 9.5, y = h_auto * 1.08, label = sprintf("k=%d saut max auto", k_auto),
color = "#ec008b", fontface = "bold", size = 3.3, hjust = 1)
}
p_lol <- p_lol +
scale_x_continuous(breaks = 1:10) +
labs(title = sprintf("Top 10 fusions (n=%d)", nrow(hcpc$data.clust)),
x = "Rang de fusion", y = "Hauteur de fusion") +
theme_minimal(base_size = 11) +
theme(plot.title = element_text(size = 11, face = "bold"))
# Côte à côte (patchwork)
p_elbow + p_lol + plot_layout(ncol = 2)
```
**Lecture rapide** — un *coude* (palier) après lequel ajouter un cluster apporte peu d'information. Les deux vues concordent : **k = `r aa_n_bestk`** correspond au dernier saut prononcé (barre bleue à gauche · point rose = saut max auto à droite). Critère croisé avec la silhouette (plus bas) et l'interprétation des clusters (parangons + v.test).
### Dendrogramme : la structure hiérarchique de la fusion
Le **dendrogramme** visualise la fusion progressive des individus en clusters, depuis les feuilles (chaque individu = 1 cluster) jusqu'à la racine (1 seul cluster global). La hauteur d'une fusion = perte d'information acceptée pour fusionner. Le trait horizontal coupe l'arbre à **`r aa_n_bestk` clusters**.
**Le dendrogramme complet** ci-dessous montre la forme de l'arbre et l'équilibre des branches ; le trait de coupe matérialise les `r aa_n_bestk` clusters retenus.
```{r}
#| label: plt-dendro-full
#| fig-width: 12
#| fig-height: 6
#| echo: false
# Dendrogramme. Sur gros n (>400), fviz_dend est O(n²) ET illisible (n feuilles) :
# on trace l'arbre complet SANS labels (leaflab="none") mais COLORÉ par cluster via
# dendextend::color_branches — rapide (qq sec) et la structure des k clusters reste
# lisible par la couleur. Fallback N&B si dendextend absent.
n_ind <- nrow(hcpc$data.clust)
tree <- hcpc$call$t$tree
if (n_ind > 400) {
dend <- stats::as.dendrogram(tree)
if (requireNamespace("dendextend", quietly = TRUE)) {
dend <- dendextend::color_branches(dend, k = aa_n_bestk,
col = unname(PAL_QUALI[seq_len(aa_n_bestk)]))
dend <- dendextend::set(dend, "branches_lwd", 1.1)
}
par(mar = c(1, 4, 3, 1))
plot(dend, leaflab = "none",
main = sprintf("Dendrogramme — n=%d ind., k=%d clusters (couleur = cluster)",
n_ind, aa_n_bestk),
ylab = "Inertie (hauteur de fusion)")
} else {
show_lab <- n_ind <= 80
cex_val <- if (n_ind <= 30) 0.7 else if (n_ind <= 60) 0.5 else 0.35
axis_text_x <- if (n_ind > 200) ggplot2::element_blank() else ggplot2::element_text()
dendro_main <- sprintf("Dendrogramme complet — k=%d clusters (n=%d ind.%s)",
aa_n_bestk, n_ind,
if (!show_lab) ", labels masqués" else "")
print(
factoextra::fviz_dend(hcpc, k = aa_n_bestk, rect = TRUE,
rect_fill = TRUE, rect_border = "gray60",
show_labels = show_lab, cex = cex_val,
k_colors = unname(PAL_QUALI[seq_len(aa_n_bestk)]),
main = dendro_main, xlab = "", ylab = "Inertie") +
theme_minimal(base_size = 11) +
theme(plot.title = element_text(size = 12, face = "bold"),
axis.text.x = axis_text_x)
)
}
```
### Silhouette : les individus sont-ils bien dans leur cluster ?
La **silhouette** d'un individu (Kaufman & Rousseeuw 1990) mesure sa cohérence avec son cluster d'appartenance : **+1** = parfaitement dans son cluster ; **0** = frontière (équidistant du voisin) ; **< 0** = mal classé. La **moyenne** donne la qualité globale : ≥ 0,50 = forte, 0,25–0,50 = raisonnable (frontières floues, profils en continuum), < 0,25 = pas de structure substantielle.
**Le graph ci-dessous** trie les individus par cluster puis par silhouette décroissante. Chaque barre = un individu, la couleur indique son cluster, la hauteur sa silhouette. **À lire** : (a) des barres uniformément hautes = cluster compact et bien séparé ; (b) une queue de barres courtes ou négatives à droite d'un cluster = profils flous ou mal classés candidats à reclassement.
*Sur ce jeu : silhouette moyenne **`r aa_v_sil`** (`r aa_t_sil_qual`), `r aa_n_neg` individus mal classés (`r aa_v_neg_pct` %).*
```{r}
#| label: plt-silhouette
#| fig-width: 10
#| fig-height: 4.5
factoextra::fviz_silhouette(sil_obj, print.summary = FALSE,
palette = unname(CLUSTER_PAL[seq_len(aa_n_bestk)])) + # MÊME palette cluster (cohérence)
labs(title = sprintf("Silhouette = %.3f — %d mal classé(s) (%.1f%%)",
aa_v_sil, aa_n_neg, aa_v_neg_pct)) +
theme_minimal(base_size = 11) +
theme(plot.title = element_text(size = 12, face = "bold"),
axis.text.x = element_blank(),
axis.ticks.x = element_blank())
```
## 7 — Caractérisation des clusters
Une fois les `r aa_n_bestk` clusters arrêtés, reste à **les nommer** : par quelles variables chaque profil se distingue-t-il ? Cette partie suit une logique **insight → détail** : d'abord la **réponse** — synthèse **4-en-1** (les profils en un coup d'œil) puis **profils narratifs** (une phrase par cluster) ; ensuite le **détail** — **heatmap v-test** (sur quelles variables exactement) et **heatmap individus** (qui est typique, qui est atypique) ; enfin la **validation externe** (la typologie tient-elle sur des variables restées hors ACP, et sur la cible ?).
### Synthèse 4-en-1 par cluster
Le tableau croise les **4 angles** en une grille dense. La colonne **variables discriminantes** sépare désormais deux blocs : **▲ sur-représenté** (au-dessus de la moyenne globale) et **▼ sous-représenté** (en dessous), top 5 par v.test **piochées dans le vivier des variables structurantes** (top η²). Suivent les modalités qualitatives sur-représentées (v.test > 1,96), les parangons (plus proches du barycentre), les individus atypiques (Mahalanobis > P95 global). `+45 %` = moyenne du cluster +45 % vs moyenne globale. Une variable suivie de **ᵖ** est **supplémentaire** (projetée : elle décrit le cluster sans avoir servi à le définir). Badge couleur cohérent avec le plot clusters.
```{r}
#| label: rt-cluster-summary
#| cache: false
# Body (pas column:page : min.exe ne rend pas la grille page-width -> tableau coupé).
# Aligné gauche sur la prose ; reactable scrolle horizontalement si besoin.
rt_cluster_summary(hcpc, indiv_df, ddict = DDICT, actives = rownames(pca$var$coord),
top_n_para = 8)
```
### Profils par cluster
Chaque cluster est caractérisé ci-dessous par une **lecture narrative dynamique** : variables nettement **au-dessus** ou **en dessous** de la moyenne (v.test + écart %), modalités qualitatives sur-représentées, parangons emblématiques. Le badge coloré reprend la couleur du cluster sur les plots factoriels. *Le nommage métier figure dans le bilan (§2), éditable hors template.*
```{r}
#| label: cluster-cards
#| cache: false
#| results: asis
# Cartes 2 par ligne : commentaire métier (cluster_comments) en tête de bulle + détail auto allégé.
render_cluster_cards(hcpc, indiv_df, ddict = DDICT, actives = rownames(pca$var$coord),
cluster_names = cluster_names, cluster_comments = cluster_comments,
top_n_para = 8)
```
### Où se situent les clusters dans le plan factoriel ?
Chaque point = un individu coloré par cluster, ellipses = enveloppes 95 %.
```{r}
#| label: intro-axes-carac
#| echo: false
#| results: asis
# Même logique qu'en §2 : lecture des axes (ccom vardyn) AVANT le biplot.
cat("::: {.encadre-insight}\n")
cat("[**Comment interpréter les axes ?**]{.encadre-insight-titre}\n\n")
cat("**Dim1 = axe horizontal (x)**, **Dim2 = axe vertical (y)** (plan principal) ; le 2ᵉ panneau croise **Dim1 × Dim3**.\n\n")
for (.i in seq_along(dim_names))
cat(sprintf("- **Dim%d — %s** : %s\n", .i, dim_names[.i], dim_comments[.i]))
cat(":::\n")
```
```{r}
#| label: plt-clusters-dual
#| echo: false
#| out-width: !expr if (isTRUE(CONFIG$cluster_plane_13)) "100%" else "72%"
#| fig-width: !expr if (isTRUE(CONFIG$cluster_plane_13)) 14 else 7
#| fig-height: !expr if (isTRUE(CONFIG$cluster_plane_13)) 6 else 6
#| fig-align: center
ind_coords <- pca$ind$coord
.cen <- apply(ind_coords[, 1:3], 2, function(v) tapply(v, hcpc$data.clust$clust, mean))
# Zoom : englobe centroïdes + ~96 % des individus (P2-P98), rogne les extrêmes -> moins de blanc.
.qx <- quantile(ind_coords[, 1], c(.02, .98)); .qy <- quantile(c(ind_coords[, 2], ind_coords[, 3]), c(.02, .98))
shared_xlim <- range(c(.qx, .cen[, 1])) * 1.02
shared_ylim <- range(c(.qy, .cen[, 2], .cen[, 3])) * 1.02
p12 <- plot_ind_cluster(pca, hcpc, axes = c(1, 2), label_parangons = TRUE, n_parangons = 6, dim_names = dim_names, cluster_names = cluster_names) +
coord_cartesian(xlim = shared_xlim, ylim = shared_ylim)
# Plan 1-3 optionnel : CONFIG$cluster_plane_13 (défaut TRUE = les 2 plans côte à côte).
combo <- if (isTRUE(CONFIG$cluster_plane_13)) {
p13 <- plot_ind_cluster(pca, hcpc, axes = c(1, 3), label_parangons = TRUE, n_parangons = 6, dim_names = dim_names, cluster_names = cluster_names) +
coord_cartesian(xlim = shared_xlim, ylim = shared_ylim)
p12 + p13 + plot_layout(ncol = 2, guides = "collect")
} else {
p12 + plot_layout(guides = "collect")
}
# Titre COMMUN (plot_annotation) ; titres de panneaux = noms de dimensions.
combo +
plot_annotation(title = sprintf("Typologie des %d clusters", aa_n_bestk),
theme = theme(plot.title = element_text(face = "bold", size = 15, hjust = 0.5))) &
theme(legend.position = "bottom")
```
[🔍 *cliquer pour zoomer*]{style="display:block;text-align:center;font-size:0.78rem;color:#8a8a8a;font-style:italic;margin-top:-0.4rem;"}
::: {.callout-tip collapse="true" appearance="minimal"}
## Détail — modalités qualitatives par cluster (v.test, % cluster, % global)
```{r}
#| label: rt-desc-quali-detail
#| cache: false
#| results: asis
if (length(CONFIG$sup_quali) == 0) {
cat("*Aucune variable qualitative supplémentaire (`_acp` = `ql`) dans cette configuration. Pour activer cette caractérisation, tagger au moins une variable (ex: `state`, `region`) comme `ql` dans le CSV ddict.*\n")
} else {
desc_ql <- typo_desc(hcpc, proba = 0.05, type = "quali")
if (nrow(desc_ql) == 0) {
cat("*Aucune modalité significative (v.test seuil p<0.05) sur les variables qualitatives sup pour les clusters retenus.*\n")
} else {
cluster_pal <- if (exists("CLUSTER_PAL")) CLUSTER_PAL else
c("1" = "#1696d2", "2" = "#fdbf11", "3" = "#55b748", "4" = "#ec008b",
"5" = "#5c5859", "6" = "#ca5800")
desc_ql %>%
group_by(cluster) %>% slice_head(n = 5) %>%
select(cluster, variable, v.test, mean_clust, mean_global) %>%
rt_table(cols = list(
cluster = colDef(name = "Cluster", width = 70, align = "center",
cell = function(v) sprintf("C%s", v),
style = function(v) {
col <- cluster_pal[[as.character(v)]] %||% "#999"
list(background = col, color = "#fff", fontWeight = "700",
borderRadius = "4px", padding = "2px 6px")
}),
variable = colDef(name = "Modalité", minWidth = 200),
v.test = colDef(name = "v.test", width = 60, align = "right",
cell = function(v) sprintf("%.1f", v),
style = function(v) list(color = if (v > 0) "#1696d2" else "#ec008b",
fontWeight = "600")),
mean_clust = colDef(name = "% cluster", width = 80, align = "right",
cell = function(v) sprintf("%.1f%%", v)),
mean_global = colDef(name = "% global", width = 80, align = "right",
cell = function(v) sprintf("%.1f%%", v),
style = list(color = "#888"))
), page_size = 20,
title = "Modalités qualitatives caractéristiques par cluster",
subtitle = "v.test, % dans le cluster, % global (seuil v.test > 1,96 · top 5 par cluster)")
}
}
```
:::
::: {.callout-tip collapse="true" appearance="minimal"}
## Détail — parangons (8 plus proches du barycentre, gradient distance)
```{r}
#| label: rt-parangons-detail
#| cache: false
parangons <- typo_parangons(hcpc, n = 8)
if (nrow(parangons) > 0) {
para_df <- parangons %>% filter(type == "paragon") %>%
select(cluster, individu, distance)
max_d <- max(para_df$distance, na.rm = TRUE)
cluster_pal <- if (exists("CLUSTER_PAL")) CLUSTER_PAL else
c("1" = "#1696d2", "2" = "#fdbf11", "3" = "#55b748", "4" = "#ec008b",
"5" = "#5c5859", "6" = "#ca5800")
para_df %>%
rt_table(cols = list(
cluster = colDef(name = "Cl.", width = 60, align = "center",
cell = function(v) sprintf("C%s", v),
style = function(v) {
col <- cluster_pal[[as.character(v)]] %||% "#999"
list(background = col, color = "#fff", fontWeight = "700",
borderRadius = "4px", padding = "2px 6px")
}),
individu = colDef(name = "Individu (id)", minWidth = 220,
style = list(fontFamily = "Fira Code, monospace", fontSize = "12px")),
distance = colDef(name = "Distance au barycentre", minWidth = 200, align = "left",
cell = function(v) {
ratio <- v / max_d
bar_w <- sprintf("%.0f%%", ratio * 100)
alpha <- sprintf("%02x", as.integer(255 * (1 - 0.7 * ratio)))
col <- paste0("#55b748", alpha)
htmltools::div(style = "display: flex; align-items: center; gap: 8px;",
htmltools::div(style = sprintf("width:%s; background:%s; height:10px; border-radius:2px;",
bar_w, col)),
htmltools::span(sprintf("%.3f", v), style = "font-size:11px; color:#444;"))
})
), page_size = 30, searchable = TRUE,
title = "Parangons — individus les plus proches du barycentre",
subtitle = "distance au centre du cluster (gradient vert · plus court = plus typique · top 8 par cluster)")
}
```
:::
::: {.callout-tip collapse="true" appearance="minimal"}
## Détail — individus atypiques (Mahalanobis > P95, top 15 triables)
```{r}
#| label: rt-outliers-detail
#| cache: false
mah_p95 <- quantile(indiv_df$mahalanobis, 0.95, na.rm = TRUE)
outliers_df <- indiv_df %>%
filter(mahalanobis > mah_p95) %>%
arrange(desc(mahalanobis)) %>%
select(individu, cluster, mahalanobis, silhouette)
if (nrow(outliers_df) > 0) {
cluster_pal <- if (exists("CLUSTER_PAL")) CLUSTER_PAL else
c("1" = "#1696d2", "2" = "#fdbf11", "3" = "#55b748", "4" = "#ec008b",
"5" = "#5c5859", "6" = "#ca5800")
max_mah <- max(outliers_df$mahalanobis, na.rm = TRUE)
outliers_df %>%
rt_table(cols = list(
individu = colDef(name = "Individu", minWidth = 180,
style = list(fontFamily = "Fira Code, monospace", fontSize = "12px")),
cluster = colDef(name = "Cluster", width = 80, align = "center",
cell = function(v) sprintf("C%s", v),
style = function(v) {
col <- cluster_pal[[as.character(v)]] %||% "#999"
list(background = col, color = "#fff", fontWeight = "700",
borderRadius = "4px", padding = "2px 6px")
}),
mahalanobis = colDef(name = "Mahalanobis", width = 140, align = "right",
cell = function(v) sprintf("%.2f", v),
style = function(v) {
norm <- min(1, v / max_mah)
alpha <- sprintf("%02x", as.integer(255 * norm * 0.5))
list(background = paste0("#ef9a9a", alpha), fontWeight = "600")
}),
silhouette = colDef(name = "Silhouette", width = 110, align = "right",
cell = function(v) sprintf("%.2f", v),
style = function(v) {
if (is.na(v)) return(list(color = "#888"))
if (v < 0) {
# mal classé : fond rouge clair
list(background = "#fdecea", color = "#c00", fontWeight = "600")
} else {
# bien classé : gradient cyan (plus intense = meilleure cohérence)
norm <- min(1, v / 0.6)
alpha <- sprintf("%02x", as.integer(255 * norm * 0.45))
list(background = paste0("#1696d2", alpha), color = "#444")
}
})
), page_size = 15, searchable = TRUE,
title = "Individus atypiques (Mahalanobis > P95)",
subtitle = "distance multivariée au profil moyen · silhouette · rouge = mal classé ET atypique (cas critique)")
}
```
*Mahalanobis > P95 = `r round(mah_p95, 2)`. **Silhouette < 0** (rouge) = individu **mal classé** ET atypique = cas critique à examiner en priorité.*
:::
### Validation externe : la typologie tient-elle hors ACP ?
La caractérisation précédente nomme les clusters par les variables qui ont **construit les axes** — un raisonnement circulaire par construction. La **validation externe** confronte la partition à des variables **restées hors de l'ACP** : si les clusters se distinguent aussi sur ces dimensions indépendantes, la structure est robuste et ne reflète pas qu'un artefact du choix des actives. La **cible** est montrée à part — c'est le test ultime : la typologie sépare-t-elle la variable d'intérêt sans l'avoir jamais vue ?
::: {.callout-tip collapse="true" appearance="minimal"}
## REPÈRES MÉTHODO : pourquoi des variables externes ?
Les variables **actives** définissent les axes, donc tout cluster s'y distingue **mécaniquement** — les citer pour « valider » la typologie est circulaire. Une variable **externe** (ni active, ni supplémentaire, ni dérivée d'une active) n'a pesé sur rien : si les clusters s'y séparent quand même (v.test significatif), c'est un signal **indépendant** de validité. Le vivier est filtré par **η²** (pouvoir discriminant global) pour ne garder que les externes qui structurent réellement la partition. La **cible** est exclue du vivier (sinon on validerait avec ce qu'on cherche à prédire) mais affichée séparément comme contrôle final.
:::
```{r}
#| label: calc-ext-validation
#| echo: false
#| cache: false
#| results: asis
# Cible depuis le ddict CSV (_targY == 'y') : ignoree par l'ACP, reutilisee ici
# comme controle de validation externe. Fallback NULL si config hardcodee.
.tgt <- NULL
.ddp <- Sys.getenv("TPLJ_DDICT", unset = "")
if (nzchar(.ddp) && file.exists(.ddp)) {
.dv <- tryCatch(read.csv(.ddp, stringsAsFactors = FALSE, check.names = FALSE),
error = function(e) NULL)
if (!is.null(.dv) && "_targY" %in% names(.dv) && "variable" %in% names(.dv)) {
.cand <- intersect(.dv$variable[!is.na(.dv[["_targY"]]) & .dv[["_targY"]] == "y"], names(df))
if (length(.cand) > 0) .tgt <- .cand[1]
}
}
# Alignement df <-> clusters par rownames (robustesse ordre/sous-echantillon HCPC).
.clust_vec <- hcpc$data.clust$clust
.df_ext <- df
if (!is.null(rownames(hcpc$data.clust)) &&
all(rownames(hcpc$data.clust) %in% rownames(df))) {
.df_ext <- df[rownames(hcpc$data.clust), , drop = FALSE]
}
ext_res <- typo_external_desc(
.df_ext, clust = .clust_vec,
actives = rownames(pca$var$coord),
sup_vars = c(CONFIG$sup_quanti, CONFIG$sup_quali),
target = .tgt)
render_external_validation(ext_res, ddict = DDICT, note_only = TRUE)
```
```{r}
#| label: rt-ext-validation
#| echo: false
#| cache: false
render_external_validation(ext_res, ddict = DDICT)
```
### Heatmap v-test : par quelles variables nommer les clusters ?
Le **v-test** mesure, pour chaque couple (cluster × variable), l'écart entre la moyenne du cluster et la moyenne globale, exprimé en écarts-types. C'est l'outil principal pour **nommer un cluster** : on cherche les variables où il se distingue le plus significativement du reste.
Le tableau classe les variables **en lignes**, triées par **eta²** (pouvoir discriminant global : combien la variable sépare bien les clusters entre eux). Les clusters sont **en colonnes**. La colonne **Nature** distingue les variables actives (qui ont construit les axes) des variables supplémentaires quantitatives (projetées). Les premières lignes = variables qui structurent le plus la typologie.
```{r}
#| label: rt-vtest-heatmap
#| cache: false
# Heatmap TRANSPOSEE : variables en lignes (labels ddict short, triées eta²),
# clusters en colonnes, colonne Nature (active / sup quanti). actives détectées
# depuis pca$var (post-transform, cohérent data.clust). En body, hauteur bornée
# (scroll interne) pour ne pas étirer la page.
typo_vtest_rt_long(hcpc, labels = DDICT,
actives = rownames(pca$var$coord),
cap_z = 2.0, seuil_zscore = 0.3,
height = "560px")
```
::: {.note-lecture}
**Variables en lignes** triées par eta² (pouvoir discriminant), **clusters en colonnes**. **Nature** : 🔵 active (a construit les axes) · ⚪ sup quanti (projetée). Cellule = moyenne du cluster, fond bordeaux (+) / bleu (−) = z-score vs moyenne globale. Pourcentage = écart relatif. **Cellule grisée/italique = écart NON significatif** (à grand n, l'exception qui mérite l'œil ; le reste est significatif par défaut). Colonne Global = moyenne sur tout l'échantillon.
:::
```{r}
#| label: comment-profil
#| echo: false
#| results: asis
cat(typo_summary_text(hcpc))
```
### Heatmap individus : qui est typique, qui est atypique ?
Le tableau ci-dessous **profile chaque individu** sur les variables actives, avec ses indicateurs de typicité dans son cluster. À utiliser pour : (1) **repérer les parangons** d'un cluster (lignes en haut quand on trie par silhouette décroissante), (2) **identifier les hors-norme** à examiner (lignes avec Mahalanobis très élevé même dans un cluster compact), (3) **lire le profil chiffré** sur chaque variable d'un individu donné (recherche par nom).
::: {.callout-tip collapse="true" appearance="minimal"}
## REPÈRES MÉTHODO : silhouette individuelle, Mahalanobis, z-score
**Silh.** = silhouette individuelle (Kaufman & Rousseeuw 1990). **+1** parfait dans son cluster ; **0** frontière (à équidistance du cluster voisin) ; **<0** mal classé (le voisin lui ressemble plus). Fond bleu intense = cohérence forte, gris = mal classé.
**Vois.** = cluster voisin le plus proche (utile pour comprendre où un mal-classé devrait basculer).
**Mah.** = distance de Mahalanobis, écart multivarié au profil moyen du cluster en tenant compte des **corrélations** entre variables. Un individu extrême sur 2 vars corrélées compte moins qu'un individu extrême sur 2 vars indépendantes. **Moyenne attendue ≈ `r aa_n_actives`** (= nombre de variables actives). Fond rose escaladant aux seuils **P75 / P90 / P95 / P99** — neutre en dessous de P75 (= individu dans la norme du cluster), rose foncé au-dessus de P95 (= atypique à examiner).
**Variables (colonnes de droite)** : valeur brute du cluster en haut, **écart % vs moyenne globale** en bas si |z-score| > 0,3. Fond **bordeaux** = au-dessus de la moyenne globale, **bleu** = en dessous, intensité = z-score (capé à |z|=2).
:::
::: {.column-page}
```{r}
#| label: rt-heatmap-individus
#| cache: false
# Toutes les vars (actives quanti z-score + sup quali catégorie). Valeurs lues dans le
# df complet (rownames = labels pour aligner sur hcpc). Labels short via DDICT. Scroll
# h+v, limité à 300 individus. Variables brutes -> short FR.
.dat_indiv <- df
if (!is.null(CONFIG$label_col) && CONFIG$label_col %in% names(.dat_indiv))
rownames(.dat_indiv) <- as.character(.dat_indiv[[CONFIG$label_col]])
.vars_indiv <- intersect(c(rownames(pca$var$coord), CONFIG$sup_quanti, CONFIG$sup_quali),
names(.dat_indiv))
typo_individus_rt(hcpc, pca, vars = .vars_indiv,
label_col = CONFIG$label_col, labels = DDICT, data = .dat_indiv,
cap_z = 2.0, band = 0.3, height = "620px", scroll = TRUE, max_rows = 300)
```
:::
::: {.note-lecture}
**Cl.** = cluster. **Silh.** = silhouette (+1 parfait, 0 frontière, <0 mal classé). **Vois.** = cluster voisin le plus proche. **Mah.** = distance de Mahalanobis (sans unité, moyenne attendue ≈ `r aa_n_actives`) — écart multivarié au profil moyen, tenant compte des corrélations. Fond rose = atypique (seuils P75/P90/P95/P99). **Variables** : valeur brute, fond bordeaux/bleu = z-score, écart % si |z| > 0.3.
:::
## Typologies comparées — toutes les configurations
::: {.callout-tip collapse="true" appearance="minimal"}
## REPÈRES MÉTHODO : comparer des typologies issues de configs différentes
Chaque onglet présente la typologie d'une **configuration ACP** de la grille (jeu de variables actives propre). Les calculs existent déjà — `acp_grid` produit le clustering de toutes les configs, pas seulement de la retenue (★).
**Piège à éviter** : les clusters ne sont **pas alignables d'une config à l'autre**. « Cluster 2 de acp_A » n'a aucun lien avec « Cluster 2 de acp_B » : numérotation arbitraire, espaces factoriels distincts. On compare la **structure des profils** (combien de familles ? lesquelles se détachent ?), pas les numéros. Le choix du modèle se tranche d'abord sur le tableau récap (§4 : silhouette, équilibre, variance), cette vue sert à *qualifier* ce que chaque config raconte.
:::
```{r}
#| label: typo-compare-tabset
#| echo: false
#| output: asis
#| cache: false
if (!is.null(grid) && length(grid$results) > 1) {
render_typo_tabset(grid$results, ddict = DDICT, selected = CONFIG$selected)
} else {
cat("*Une seule configuration ACP dans la grille — pas de comparaison de typologies.*\n")
}
```
## 8 — Qualité : la typologie tient-elle ?
Synthèse des indicateurs de robustesse de la partition.
- **Équilibre** : taille des clusters (un cluster < 5 % des individus est fragile, à fusionner ou expliquer)
- **Cohérence interne** : silhouette moyenne (≥ 0,50 = forte, 0,25–0,50 = raisonnable, < 0,25 = clusters chevauchants — typique des continuums territoriaux)
- **Inertie inter** : part de la variance totale captée par la séparation entre clusters. La **consolidation k-means** raffine légèrement les frontières après HCPC.
```{r}
#| label: quality-summary
#| echo: false
#| results: asis
cat(sprintf("- **%d clusters**, %d individus (%s)\n", aa_n_bestk, aa_n_ind, aa_t_sizes))
cat(sprintf("- Silhouette : **%.3f** (%s)\n", aa_v_sil, aa_t_sil_qual))
cat(sprintf("- Mal classés : %d (%.1f %%)\n", aa_n_neg, aa_v_neg_pct))
if (!is.na(inert$inertie_inter_apres))
cat(sprintf("- Inertie inter : **%.3f** (gain consolid. %+.1f %%)\n",
inert$inertie_inter_apres, if (!is.na(inert$gain_consol_pct)) inert$gain_consol_pct else 0))
```
::: {.callout-tip collapse='true' appearance='minimal'}
## REPÈRES MÉTHODO : quand une silhouette modeste reste valable
Sur **données territoriales / sciences sociales**, une silhouette de 0,20–0,40 est fréquente et ne signale pas un échec : elle traduit le fait que les profils forment un **continuum** plutôt que des catégories tranchées. La typologie reste utile pour :
- nommer des **gradients** (plus dynamique vs plus rural) plutôt que des catégories étanches ;
- identifier les **parangons** (cas typiques) et les **transitions** (cas-frontière) ;
- piloter des politiques différenciées sans prétendre que les groupes sont hermétiques.
Validation par triangulation : silhouette + Calinski-Harabasz + Davies-Bouldin + Gap stat + stabilité bootstrap (`fpc::clusterboot()`, Jaccard moyen >0,75). Rejet de la typologie uniquement si silhouette <0,15 sur l'ensemble des métriques.
:::