idx_gentri IRIS — Diagnostics et sensibilité

Analyse exploratoire · Construction, robustesse, croisement SIRENE

Notebook
EDA
Gentrification
Auteur·rice

Vincent R.

Date de publication

2026

Cadrage — L’indice couvre 1 413 IRIS Paris+PC (47 communes) et 352 IRIS Marseille (16 arrondissements), soit 4,4 millions d’habitants. Les médianes T2 sont quasi identiques (49,3 vs 49,7) mais masquent des dispersions très différentes : le 93 concentre les extrêmes hauts (médiane 53,1), le 92 les plus bas (47,6).

Robustesse — L’indice passe les tests de sensibilité : leave-one-out ρ ≥ 0,934, pondération double PCS ρ = 0,964. Seul le cap ±3 montre un point d’attention (ρ = 0,66 Paris) : quelques IRIS extrêmes sont sensibles au plafonnement.

Structure factorielle — L’ACP confirme un indice multidimensionnel (Dim1 = 24–27 %, KMO ≈ 0,50). Paris est structuré par le gradient CSP (cadres +0,85), Marseille par les prix DVF (+0,67). Ce sont deux processus de gentrification distincts.

Typologie — La CAH identifie 4 profils-types par périmètre. À Paris, les prix immobiliers sont le premier moteur (11/47 communes). 21 IRIS cumulent les 7 composantes positives, concentrés dans le 11e, 19e, 20e et le canal de l’Ourcq (Pantin, Montreuil, Saint-Denis).

Temporalité — T2 (2016→2022) et Long (2011→2022) corrèlent à ρ = 0,66 sur 1 411 IRIS, avec 5 trajectoires émergentes et 7 stabilisées. Surprise : T1 (2011→2016) et T2 sont quasi décorrélés (ρ ≈ 0) — le classement des IRIS change complètement entre les deux sous-périodes. Cette décorrélation s’explique principalement par le changement de composition de l’indice (6 vs 7 composantes, sans DVF prix en T1) et la restandardisation des z-scores par période.

1 Cadrage EDA

Ce premier volet pose le cadre statistique : combien d’IRIS, quelle population, quelle couverture des 7 composantes de l’indice. On compare ensuite les deux périmètres (Paris+PC et Marseille) sur les indicateurs de contexte, les composantes de l’indice et les variables complémentaires. Les distributions, violins et corrélations permettent de repérer les asymétries et les surprises avant d’entrer dans les tests de robustesse.

1.1 Périmètre et couverture des données

Code
perim_summary <- df_all |>
  group_by(perimetre) |>
  summarise(
    n_iris       = n(),
    n_communes   = n_distinct(COM),
    n_dept       = n_distinct(dep),
    pop_totale   = sum(P22_POP, na.rm = TRUE),
    n_idx_t1     = sum(!is.na(idxg_t1_ind)),
    n_idx_t2     = sum(!is.na(idxg_t2_ind)),
    n_idx_long   = sum(!is.na(idxg_ind)),
    med_ncomp_t2 = median(idxg_t2_ncomp, na.rm = TRUE),
    .groups = "drop"
  )

reactable(perim_summary,
  pagination = FALSE, compact = TRUE,
  columns = list(
    perimetre    = colDef("Périmètre", width = 120),
    n_iris       = colDef("IRIS", width = 80, format = colFormat(separators = TRUE)),
    n_communes   = colDef("Communes", width = 90),
    n_dept       = colDef("Depts", width = 60),
    pop_totale   = colDef("Pop. 2022", width = 120, format = colFormat(separators = TRUE)),
    n_idx_t1     = colDef("idx T1 OK", width = 90, format = colFormat(separators = TRUE)),
    n_idx_t2     = colDef("idx T2 OK", width = 90, format = colFormat(separators = TRUE)),
    n_idx_long   = colDef("idx Long OK", width = 100, format = colFormat(separators = TRUE)),
    med_ncomp_t2 = colDef("Composantes (méd.)", width = 130)
  ))
Table 1

1.2 Couverture par composante

Code
calc_couv <- function(df, label) {
  map_dfr(names(Z_T2), function(nm) {
    col <- Z_T2[[nm]]
    tibble(
      composante     = Z_LABELS[[nm]],
      perimetre      = label,
      pct_renseigne  = if (col %in% names(df)) round(sum(!is.na(df[[col]])) / nrow(df) * 100, 1) else NA_real_,
      n_renseigne    = if (col %in% names(df)) sum(!is.na(df[[col]])) else NA_integer_
    )
  })
}

couv <- bind_rows(
  calc_couv(df_paris, "Paris+PC"),
  calc_couv(df_mars, "Marseille")
)

couv_wide <- couv |>
  select(composante, perimetre, pct_renseigne) |>
  pivot_wider(names_from = perimetre, values_from = pct_renseigne)

reactable(couv_wide,
  pagination = FALSE, compact = TRUE,
  columns = list(
    composante  = colDef("Composante", width = 200),
    `Paris+PC`  = colDef("Paris+PC (%)", width = 120,
                         style = function(val) if (!is.na(val) && val < 80) list(color = "#ec008b") else list()),
    Marseille   = colDef("Marseille (%)", width = 120,
                         style = function(val) if (!is.na(val) && val < 80) list(color = "#ec008b") else list())
  ))
Table 2

La couverture DVF dépasse 93 % sur les deux périmètres après filtrage qualité — les composantes immobilières sont exploitables sans biais de sélection majeur. Les 4-7 % manquants correspondent à des IRIS sans transaction sur la période (zones d’habitat social principalement).

Les composantes DVF (prix, ratio) sont optionnelles : leur couverture plus faible n’invalide pas l’indice grâce au calcul adaptatif (ncomp variable).

1.3 Cadrage — Deux territoires, deux dynamiques

Code
# &s &CADRAGE_RECAP - Tableau récap pondéré pop Paris+PC vs Marseille

build_recap_row <- function(df, label) {
  pop <- sum(df$P22_POP, na.rm = TRUE)
  n_com <- n_distinct(df$COM)
  wmean <- function(col) {
    v <- df[[col]]; p <- df$P22_POP
    ok <- !is.na(v) & !is.na(p) & p > 0
    if (sum(ok) == 0) return(NA_real_)
    sum(v[ok] * p[ok]) / sum(p[ok])
  }
  tibble(
    perimetre = label,
    pop = pop, n_com = n_com, n_iris = nrow(df),
    tcam_pop = wmean("dm_pop_vtcam_1622"),
    cadres_pct = wmean("dsp_csp_cadres_pct_22"),
    cadres_evol = wmean("dsp_csp_cadres_vdifp_1622"),
    ouvriers_pct = wmean("dsp_csp_ouvriers_pct_22"),
    ouvriers_evol = wmean("dsp_csp_ouvriers_vdifp_1622"),
    proprio_pct = wmean("log_prop_pct_22"),
    proprio_evol = wmean("log_prop_vdifp_1622"),
    emmenrec_pct = wmean("log_emmenrec_pct_22"),
    emmenrec_evol = wmean("log_emmenrec_vdifp_1622"),
    px_appt = mean(df$logd_px2q2_appt_24, na.rm = TRUE),
    px_evol = mean(df$logd_px2_appt_vevol_1924, na.rm = TRUE),
    rev_med = wmean("rev_med_21"),
    rev_evol = wmean("rev_med_vevol_1921"),
    txpauv = wmean("rev_txpauv_21"),
    txpauv_evol = wmean("rev_txpauv_vdifp_1921"),
    d1 = wmean("rev_d1_21"),
    d9 = wmean("rev_d9_21"),
    imm_pct = wmean("dm_imm_pct_22"),
    imm_evol = wmean("dm_imm_pct_vdifp_1622")
  )
}

recap_paris <- build_recap_row(df_paris, "Paris+PC")
recap_mars  <- build_recap_row(df_mars, "Marseille")
recap <- bind_rows(recap_paris, recap_mars)

# Pivoter en format indicateur × territoire
indic_labels <- c(
  "Population", "Communes / ARM", "IRIS",
  "TCAM pop 16-22 (%/an)",
  "Part cadres (%)", "Évol. cadres (pts %)",
  "Part ouvriers (%)", "Évol. ouvriers (pts %)",
  "Part propriétaires (%)", "Évol. propriétaires (pts %)",
  "Part emménagés récents (%)", "Évol. emménagés (pts %)",
  "Prix m² appt (€)", "Évol. prix appt (%)",
  "Revenu médian (€/an)", "Évol. revenu médian (%)",
  "Taux pauvreté (%)", "Évol. tx pauvreté (pts %)",
  "D1 revenu (€)", "D9 revenu (€)",
  "Part immigrés (%)", "Évol. immigrés (pts %)"
)
indic_section <- c(
  rep("Contexte", 4),
  rep("Composantes idx", 10),
  rep("Complémentaires", 8)
)

vals_paris <- c(
  format(recap$pop[1], big.mark = " "), as.character(recap$n_com[1]), format(recap$n_iris[1], big.mark = " "),
  sprintf("%+.2f", recap$tcam_pop[1]),
  sprintf("%.1f", recap$cadres_pct[1]), sprintf("%+.1f", recap$cadres_evol[1]),
  sprintf("%.1f", recap$ouvriers_pct[1]), sprintf("%+.1f", recap$ouvriers_evol[1]),
  sprintf("%.1f", recap$proprio_pct[1]), sprintf("%+.1f", recap$proprio_evol[1]),
  sprintf("%.1f", recap$emmenrec_pct[1]), sprintf("%+.1f", recap$emmenrec_evol[1]),
  format(round(recap$px_appt[1]), big.mark = " "), sprintf("%+.1f", recap$px_evol[1]),
  format(round(recap$rev_med[1]), big.mark = " "), sprintf("%+.1f", recap$rev_evol[1]),
  sprintf("%.1f", recap$txpauv[1]), sprintf("%+.1f", recap$txpauv_evol[1]),
  format(round(recap$d1[1]), big.mark = " "), format(round(recap$d9[1]), big.mark = " "),
  sprintf("%.1f", recap$imm_pct[1]), sprintf("%+.1f", recap$imm_evol[1])
)
vals_mars <- c(
  format(recap$pop[2], big.mark = " "), as.character(recap$n_com[2]), format(recap$n_iris[2], big.mark = " "),
  sprintf("%+.2f", recap$tcam_pop[2]),
  sprintf("%.1f", recap$cadres_pct[2]), sprintf("%+.1f", recap$cadres_evol[2]),
  sprintf("%.1f", recap$ouvriers_pct[2]), sprintf("%+.1f", recap$ouvriers_evol[2]),
  sprintf("%.1f", recap$proprio_pct[2]), sprintf("%+.1f", recap$proprio_evol[2]),
  sprintf("%.1f", recap$emmenrec_pct[2]), sprintf("%+.1f", recap$emmenrec_evol[2]),
  format(round(recap$px_appt[2]), big.mark = " "), sprintf("%+.1f", recap$px_evol[2]),
  format(round(recap$rev_med[2]), big.mark = " "), sprintf("%+.1f", recap$rev_evol[2]),
  sprintf("%.1f", recap$txpauv[2]), sprintf("%+.1f", recap$txpauv_evol[2]),
  format(round(recap$d1[2]), big.mark = " "), format(round(recap$d9[2]), big.mark = " "),
  sprintf("%.1f", recap$imm_pct[2]), sprintf("%+.1f", recap$imm_evol[2])
)

tbl_recap <- tibble(
  Section = indic_section,
  Indicateur = indic_labels,
  `Paris+PC` = vals_paris,
  Marseille = vals_mars
)

reactable(tbl_recap,
  pagination = FALSE, compact = TRUE,
  groupBy = "Section",
  defaultExpanded = TRUE,
  columns = list(
    Section    = colDef(width = 140),
    Indicateur = colDef(width = 250),
    `Paris+PC` = colDef(width = 130, align = "right",
      style = list(fontWeight = "600", fontFamily = "JetBrains Mono, monospace", fontSize = "0.85em")),
    Marseille  = colDef(width = 130, align = "right",
      style = list(fontWeight = "600", fontFamily = "JetBrains Mono, monospace", fontSize = "0.85em"))
  ),
  theme = reactableTheme(
    headerStyle = list(borderBottom = "2px solid #1696d2", fontSize = "0.85em"),
    groupHeaderStyle = list(fontWeight = "bold", color = "#1696d2", fontSize = "0.9em")
  ))
# &e
Table 3

Deux territoires structurellement opposés. Paris+PC concentre 29% de cadres (×2.7 vs Marseille) mais son marché immobilier stagne (-3.1% en 5 ans) là où Marseille explose (+24.0%). Le taux de pauvreté marseillais (35%) dépasse Paris de 13 points. Les propriétaires sont inversés : Marseille 43% vs Paris 34%, mais Marseille perd des propriétaires (-1.2 pts) pendant que Paris en gagne. La gentrification parisienne est socioprofessionnelle (cadres +3.4 pts), la marseillaise est immobilière (prix +24%).

Moyennes pondérées par population IRIS. Prix DVF : moyenne simple (pondération par transactions non disponible à l’IRIS). Périmètre : 47 communes Paris+PC, 16 arrondissements Marseille.

1.4 Distribution de l’indice

Code
df_idx <- df_all |> filter(!is.na(idxg_t2_ind))

med_p <- median(df_paris$idxg_t2_ind, na.rm = TRUE)
med_m <- median(df_mars$idxg_t2_ind, na.rm = TRUE)

ggplot(df_idx, aes(x = idxg_t2_ind, y = after_stat(density), fill = perimetre)) +
  geom_histogram(bins = 50, alpha = 0.6, position = "identity") +
  geom_vline(xintercept = med_p, linetype = "dashed", color = col_cyan, linewidth = 0.7) +
  geom_vline(xintercept = med_m, linetype = "dashed", color = col_magenta, linewidth = 0.7) +
  annotate("text", x = med_p + 2, y = Inf, vjust = 2, label = sprintf("méd. Paris+PC = %.1f", med_p),
           color = col_cyan, size = 3, hjust = 0) +
  annotate("text", x = med_m - 2, y = Inf, vjust = 3.5, label = sprintf("méd. Marseille = %.1f", med_m),
           color = col_magenta, size = 3, hjust = 1) +
  scale_fill_manual(values = c("Paris+PC" = col_cyan, "Marseille" = col_magenta)) +
  theme_urbn() +
  labs(
    title    = "Distribution de l'indice de gentrification T2 (2016-2022)",
    subtitle = sprintf("%s IRIS Paris+PC · %s IRIS Marseille · échelle 0-100",
                       format(n_paris, big.mark = " "), format(n_mars, big.mark = " ")),
    x = "idx_gentri T2 (0-100)", y = "Densité (%)", fill = NULL,
    caption  = make_source("RP INSEE 2016-2022", "Filosofi 2019-2021", "DVF 2016-2024")
  )

Comparaison des trois périodes — L’indice est calculé sur T1 (2011→2016, 6 composantes sans DVF prix), T2 (2016→2022, 7 composantes) et Long (2011→2022, 7 composantes). La comparaison de leurs distributions respectives révèle si le processus de gentrification se concentre, se diffuse ou se déplace entre les sous-périodes. T1 utilise 6 composantes (pas de DVF prix, non disponible avant 2016) : sa distribution est donc mécaniquement plus resserrée.

Code
# Empiler les 3 périodes pour Paris+PC
df_3p_paris <- bind_rows(
  df_paris |> filter(!is.na(idxg_t1_ind)) |>
    transmute(code, dep, idx = idxg_t1_ind, periode = "T1 (11→16)"),
  df_paris |> filter(!is.na(idxg_t2_ind)) |>
    transmute(code, dep, idx = idxg_t2_ind, periode = "T2 (16→22)"),
  df_paris |> filter(!is.na(idxg_ind)) |>
    transmute(code, dep, idx = idxg_ind, periode = "Long (11→22)")
)

df_3p_mars <- bind_rows(
  df_mars |> filter(!is.na(idxg_t1_ind)) |>
    transmute(code, dep, idx = idxg_t1_ind, periode = "T1 (11→16)"),
  df_mars |> filter(!is.na(idxg_t2_ind)) |>
    transmute(code, dep, idx = idxg_t2_ind, periode = "T2 (16→22)"),
  df_mars |> filter(!is.na(idxg_ind)) |>
    transmute(code, dep, idx = idxg_ind, periode = "Long (11→22)")
)

p_3p_paris <- ggplot(df_3p_paris, aes(x = idx, color = periode, fill = periode)) +
  geom_density(alpha = 0.15, linewidth = 0.8) +
  scale_color_manual(values = c("T1 (11→16)" = "#E69F00", "T2 (16→22)" = col_cyan,
                                "Long (11→22)" = "#7570B3")) +
  scale_fill_manual(values = c("T1 (11→16)" = "#E69F00", "T2 (16→22)" = col_cyan,
                                "Long (11→22)" = "#7570B3")) +
  theme_urbn() +
  labs(title = "Paris+PC — Densité idx_gentri par période",
       subtitle = sprintf("T1 : %d IRIS (6 comp.) · T2 : %d (7 comp.) · Long : %d (7 comp.)",
                          n_t1_paris, n_paris, sum(!is.na(df_paris$idxg_ind))),
       x = "idx_gentri (0-100)", y = "Densité", color = NULL, fill = NULL)

p_3p_mars <- ggplot(df_3p_mars, aes(x = idx, color = periode, fill = periode)) +
  geom_density(alpha = 0.15, linewidth = 0.8) +
  scale_color_manual(values = c("T1 (11→16)" = "#E69F00", "T2 (16→22)" = col_magenta,
                                "Long (11→22)" = "#7570B3")) +
  scale_fill_manual(values = c("T1 (11→16)" = "#E69F00", "T2 (16→22)" = col_magenta,
                                "Long (11→22)" = "#7570B3")) +
  theme_urbn() +
  labs(title = "Marseille — Densité idx_gentri par période",
       subtitle = sprintf("T1 : %d IRIS (6 comp.) · T2 : %d (7 comp.) · Long : %d (7 comp.)",
                          n_t1_mars, n_mars, sum(!is.na(df_mars$idxg_ind))),
       x = "idx_gentri (0-100)", y = "Densité", color = NULL, fill = NULL)

library(patchwork)
p_3p_paris + p_3p_mars +
  plot_layout(guides = "collect") &
  theme(legend.position = "bottom")

Code
# Stats descriptives par période et périmètre
stats_3p <- bind_rows(
  df_paris |> filter(!is.na(idxg_t1_ind)) |>
    summarise(perimetre = "Paris+PC", periode = "T1 (11→16)", n = n(),
              med = median(idxg_t1_ind), Q1 = quantile(idxg_t1_ind, 0.25),
              Q3 = quantile(idxg_t1_ind, 0.75), sd = sd(idxg_t1_ind)),
  df_paris |> filter(!is.na(idxg_t2_ind)) |>
    summarise(perimetre = "Paris+PC", periode = "T2 (16→22)", n = n(),
              med = median(idxg_t2_ind), Q1 = quantile(idxg_t2_ind, 0.25),
              Q3 = quantile(idxg_t2_ind, 0.75), sd = sd(idxg_t2_ind)),
  df_paris |> filter(!is.na(idxg_ind)) |>
    summarise(perimetre = "Paris+PC", periode = "Long (11→22)", n = n(),
              med = median(idxg_ind), Q1 = quantile(idxg_ind, 0.25),
              Q3 = quantile(idxg_ind, 0.75), sd = sd(idxg_ind)),
  df_mars |> filter(!is.na(idxg_t1_ind)) |>
    summarise(perimetre = "Marseille", periode = "T1 (11→16)", n = n(),
              med = median(idxg_t1_ind), Q1 = quantile(idxg_t1_ind, 0.25),
              Q3 = quantile(idxg_t1_ind, 0.75), sd = sd(idxg_t1_ind)),
  df_mars |> filter(!is.na(idxg_t2_ind)) |>
    summarise(perimetre = "Marseille", periode = "T2 (16→22)", n = n(),
              med = median(idxg_t2_ind), Q1 = quantile(idxg_t2_ind, 0.25),
              Q3 = quantile(idxg_t2_ind, 0.75), sd = sd(idxg_t2_ind)),
  df_mars |> filter(!is.na(idxg_ind)) |>
    summarise(perimetre = "Marseille", periode = "Long (11→22)", n = n(),
              med = median(idxg_ind), Q1 = quantile(idxg_ind, 0.25),
              Q3 = quantile(idxg_ind, 0.75), sd = sd(idxg_ind))
)

reactable(stats_3p,
  pagination = FALSE, compact = TRUE,
  defaultColDef = colDef(format = colFormat(digits = 1)),
  columns = list(
    perimetre = colDef("Périmètre", width = 110, format = NULL),
    periode   = colDef("Période", width = 110, format = NULL),
    n         = colDef("N", width = 70, format = colFormat(separators = TRUE)),
    med       = colDef("Médiane", width = 80),
    Q1        = colDef("Q1", width = 60),
    Q3        = colDef("Q3", width = 60),
    sd        = colDef("Écart-type", width = 90)
  ))
Table 4
Code
df_idx_dept <- df_all |>
  filter(!is.na(idxg_t2_ind)) |>
  mutate(dep_label = case_when(
    dep == "75" ~ "75 Paris",
    dep == "92" ~ "92 Hauts-de-Seine",
    dep == "93" ~ "93 Seine-Saint-Denis",
    dep == "94" ~ "94 Val-de-Marne",
    dep == "13" ~ "13 Bouches-du-Rhône",
    TRUE ~ dep
  ))

ggplot(df_idx_dept, aes(x = reorder(dep_label, idxg_t2_ind, FUN = median), y = idxg_t2_ind, fill = perimetre)) +
  geom_boxplot(alpha = 0.7, outlier.size = 0.8) +
  coord_flip() +
  scale_fill_manual(values = c("Paris+PC" = col_cyan, "Marseille" = col_magenta)) +
  theme_urbn() +
  labs(
    title    = "Dispersion de l'idx_gentri par département",
    subtitle = "Période T2 (2016-2022) · Médiane, Q1-Q3, moustaches 1.5×IQR",
    x = NULL, y = "idx_gentri T2 (0-100)", fill = NULL,
    caption  = make_source("RP INSEE", "Filosofi", "DVF")
  )

Code
stats_idx <- df_all |>
  filter(!is.na(idxg_t2_ind)) |>
  group_by(perimetre) |>
  summarise(
    n    = n(),
    min  = min(idxg_t2_ind, na.rm = TRUE),
    Q1   = quantile(idxg_t2_ind, 0.25, na.rm = TRUE),
    med  = median(idxg_t2_ind, na.rm = TRUE),
    Q3   = quantile(idxg_t2_ind, 0.75, na.rm = TRUE),
    max  = max(idxg_t2_ind, na.rm = TRUE),
    sd   = sd(idxg_t2_ind, na.rm = TRUE),
    cv   = sd / mean(idxg_t2_ind, na.rm = TRUE) * 100,
    .groups = "drop"
  )

reactable(stats_idx,
  pagination = FALSE, compact = TRUE,
  defaultColDef = colDef(format = colFormat(digits = 1)),
  columns = list(
    perimetre = colDef("Périmètre", width = 120, format = NULL),
    n = colDef("N", width = 70, format = colFormat(separators = TRUE)),
    cv = colDef("CV (%)", width = 80)
  ))
Table 5

1.5 Communes extrêmes

Vue commune — les 3 communes les plus gentrifiées sont en Seine-Saint-Denis (93) : Saint-Ouen (58.8), Romainville (57.3), Le Pré-Saint-Gervais (56.5). Le 92 sud (Neuilly, Boulogne) est en bas du classement — quartiers déjà aisés où l’indice capte peu de changement. Cette lecture communale filtre le bruit IRIS et révèle le gradient géographique nord-est/sud-ouest.

Code
df_communes <- df_paris |>
  filter(!is.na(idxg_t2_ind)) |>
  group_by(COM, libelle_com, dep) |>
  summarise(
    idx_gentri = weighted.mean(idxg_t2_ind, P22_POP, na.rm = TRUE),
    n_iris     = n(),
    pop        = sum(P22_POP, na.rm = TRUE),
    .groups    = "drop"
  ) |>
  arrange(desc(idx_gentri))

top10 <- head(df_communes, 10) |> mutate(rang = "Top 10")
bot10 <- tail(df_communes, 10) |> mutate(rang = "Bottom 10")
topbot <- bind_rows(top10, bot10)

reactable(topbot,
  pagination = FALSE, compact = TRUE,
  groupBy = "rang",
  columns = list(
    rang        = colDef("Groupe", width = 100),
    COM         = colDef("Code", width = 70),
    libelle_com = colDef("Commune", width = 200),
    dep         = colDef("Dept", width = 50),
    idx_gentri  = colDef("idx_gentri", width = 100, format = colFormat(digits = 1)),
    n_iris      = colDef("IRIS", width = 60),
    pop         = colDef("Pop.", width = 100, format = colFormat(separators = TRUE))
  ))
Table 6

La Seine-Saint-Denis (93) affiche la médiane la plus haute (53.1) et la plus forte dispersion (IQR=10.0) — gentrification intense mais très hétérogène selon les communes. Paris intra-muros est plus homogène (méd=49.4, IQR=7.1). Les Hauts-de-Seine (92) sont les moins “gentrifiés” (méd=47.6) — quartiers déjà aisés, peu de changement social. Les deux périmètres affichent des médianes quasi identiques (49.3 Paris+PC contre 49.7 Marseille, Δ=−0.4 point) mais des structures départementales très contrastées. La Seine-Saint-Denis se distingue par une médiane élevée (53.1) et un IQR de 10.0 — le département connaît la gentrification la plus intense et la plus hétérogène. À l’opposé, les Hauts-de-Seine (méd=47.6) et le Val-de-Marne (méd=48.5) restent en deçà de la moyenne : quartiers déjà aisés ou stables, où l’indice capte peu de changement. À l’échelle communale, le top 3 se concentre dans le nord du 93 — Saint-Ouen-sur-Seine (58.8), Romainville (57.3), Le Pré-Saint-Gervais (56.5) — confirmant un gradient géographique nord-est / sud-ouest que la carte IRIS détaillera.

1.6 Les 7 composantes z-scores

Code
# Pivot long pour violin : z-scores T2 — Paris+PC (agrégé) vs Marseille
df_z_long <- df_all |>
  select(code, perimetre, all_of(unname(Z_T2))) |>
  pivot_longer(cols = all_of(unname(Z_T2)), names_to = "col", values_to = "z") |>
  filter(!is.na(z)) |>
  mutate(
    composante = factor(names(Z_T2)[match(col, Z_T2)], levels = names(Z_T2)),
    label      = Z_LABELS[as.character(composante)]
  )

ggplot(df_z_long, aes(x = label, y = z, fill = perimetre)) +
  geom_violin(alpha = 0.6, scale = "width", position = position_dodge(0.8)) +
  geom_hline(yintercept = c(-3, 3), linetype = "dotted", color = "#999999", linewidth = 0.4) +
  geom_hline(yintercept = 0, color = "#444444", linewidth = 0.3) +
  scale_fill_manual(values = c("Paris+PC" = col_cyan, "Marseille" = col_magenta)) +
  coord_flip() +
  theme_urbn() +
  labs(
    title    = "Distribution des z-scores par composante — Paris+PC vs Marseille",
    subtitle = "Période T2 (2016-2022) · Lignes pointillées = cap +/-3",
    x = NULL, y = "Z-score (cappe +/-3)", fill = NULL,
    caption  = make_source("RP INSEE 2016-2022", "Filosofi 2019-2021", "DVF 2016-2024")
  )

1.7 Proportion IRIS cappés par composante

Code
calc_zstats <- function(df, label) {
  map_dfr(names(Z_T2), function(nm) {
    col <- Z_T2[[nm]]
    vals <- df[[col]]
    vals <- vals[!is.na(vals)]
    n_total <- length(vals)
    tibble(
      composante    = Z_LABELS[[nm]],
      perimetre     = label,
      n             = n_total,
      mean_z        = round(mean(vals), 2),
      sd_z          = round(sd(vals), 2),
      pct_cap_neg   = round(sum(vals <= -2.99) / n_total * 100, 1),
      pct_cap_pos   = round(sum(vals >= 2.99) / n_total * 100, 1),
      pct_cap_total = round((sum(vals <= -2.99) + sum(vals >= 2.99)) / n_total * 100, 1)
    )
  })
}

zstats <- bind_rows(
  calc_zstats(df_paris, "Paris+PC"),
  calc_zstats(df_mars, "Marseille")
)

reactable(zstats,
  pagination = FALSE, compact = TRUE,
  groupBy = "perimetre",
  defaultColDef = colDef(format = colFormat(digits = 1)),
  columns = list(
    perimetre     = colDef("Périmètre", width = 100),
    composante    = colDef("Composante", width = 200, format = NULL),
    n             = colDef("N", width = 60, format = colFormat(separators = TRUE)),
    mean_z        = colDef("Moy. z", width = 70),
    sd_z          = colDef("ET z", width = 70),
    pct_cap_neg   = colDef("% cap −3", width = 80,
                           style = function(val) if (!is.na(val) && val > 3) list(color = "#ec008b", fontWeight = "bold") else list()),
    pct_cap_pos   = colDef("% cap +3", width = 80,
                           style = function(val) if (!is.na(val) && val > 3) list(color = "#ec008b", fontWeight = "bold") else list()),
    pct_cap_total = colDef("% cap total", width = 90,
                           style = function(val) if (!is.na(val) && val > 3) list(color = "#ec008b", fontWeight = "bold") else list())
  ))
Table 7
Astuce

Seuil : Si le % d’IRIS cappés dépasse 3 % pour une composante, le cap influence le classement et nécessite investigation.

1.8 Focus emménagés récents et DVF

Code
# 2 histogrammes côte à côte : emménagés récents (niveau) + prix DVF (évolution)
p_emmenrec <- df_paris |>
  filter(!is.na(log_emmenrec_pct_22)) |>
  ggplot(aes(x = log_emmenrec_pct_22, fill = dep)) +
  geom_histogram(bins = 40, alpha = 0.6, position = "identity") +
  scale_fill_manual(values = c("75" = col_cyan, "92" = col_green,
                                "93" = col_yellow, "94" = col_magenta)) +
  theme_urbn() +
  labs(title = "Part emménagés récents 2022 (%)", subtitle = "Paris+PC par département",
       x = "% emménagés < 5 ans", y = "Fréquence", fill = "Dept")

p_dvf <- df_paris |>
  filter(!is.na(logd_px2_glb_vevol_1924)) |>
  ggplot(aes(x = logd_px2_glb_vevol_1924, fill = dep)) +
  geom_histogram(bins = 40, alpha = 0.6, position = "identity") +
  geom_vline(xintercept = 0, linetype = "dashed", color = "#444444") +
  scale_fill_manual(values = c("75" = col_cyan, "92" = col_green,
                                "93" = col_yellow, "94" = col_magenta)) +
  theme_urbn() +
  labs(title = "Évol. prix DVF 2019-2024 (%)", subtitle = "Paris+PC par département",
       x = "Variation prix m² (%)", y = "Fréquence", fill = "Dept")

gridExtra::grid.arrange(p_emmenrec, p_dvf, ncol = 2)

Les emménagés récents sont la composante la plus indépendante (corrélation max = 0.244 avec cadres). Ils captent une dimension de mobilité résidentielle distincte du remplacement PCS. Les composantes DVF (prix, ratio) sont quasi orthogonales aux composantes RP — elles enrichissent l’indice sans redondance. Les 7 composantes présentent des z-scores centrés (moyennes < 0.01 en valeur absolue) et des écarts-types proches de 1 (0.89 à 0.97), confirmant que la standardisation fonctionne correctement. Le cap ±3 affecte au maximum 1.6 % des IRIS (composante prix DVF), très en dessous du seuil d’alerte de 3 %. Les emménagés récents constituent la dimension la plus indépendante du système (corrélation maximale = 0.244 avec cadres) : ils captent une mobilité résidentielle distincte du remplacement socioprofessionnel. Les deux composantes DVF (évolution des prix et ratio prix/revenu) restent quasi orthogonales aux composantes RP (ρ < 0.23) — elles enrichissent l’indice sans redondance, ce qui justifie leur maintien comme composantes optionnelles.

1.9 Matrice de corrélation Spearman

On s’attendrait a priori à une corrélation forte (ρ > 0.70) entre l’arrivée de cadres et le recul des ouvriers — un remplacement mécanique “un pour un” souvent postulé dans la littérature sur la gentrification. La matrice ci-dessous teste cette hypothèse sur les 7 composantes de l’indice.

Code
make_corr_reactable(mat_paris, digits = 2, show_diagonal = FALSE)
Table 8
Code
make_corr_reactable(mat_mars, digits = 2, show_diagonal = FALSE)
Table 9
Code
make_corr_pairs_reactable(mat_paris, min_abs = 0.20)
Table 10
Code
make_corr_reactable(mat_ext_paris, digits = 2, show_diagonal = FALSE)
Table 11
Code
make_corr_reactable(mat_ext_mars, digits = 2, show_diagonal = FALSE)
Table 12

Cadres×ouvriers ρ=0.368 — plus faible qu’attendu : l’arrivée de cadres et le départ d’ouvriers ne touchent pas les mêmes IRIS. La gentrification à l’IRIS n’est pas un remplacement direct 1:1 mais un processus multi-sites. Bonne nouvelle pour l’indice : les 7 composantes sont suffisamment indépendantes (aucune paire > 0.50) pour justifier leur agrégation.

La matrice Spearman 7×7 révèle une structure à composantes faiblement corrélées — aucune paire ne dépasse ρ=0.50. La corrélation la plus forte (cadres × ouvriers, ρ=0.368) est nettement inférieure au ρ > 0.70 qu’on attendrait d’un remplacement mécanique cadres-pour-ouvriers. L’arrivée de cadres et le recul ouvrier ne touchent pas les mêmes IRIS dans les mêmes proportions : la gentrification est un processus multi-sites plutôt qu’un remplacement direct. La corrélation la plus faible (revenu médian × ratio prix/revenu, ρ=−0.005) confirme que ces deux indicateurs mesurent des dimensions orthogonales. Ce portrait valide la construction de l’indice : 7 composantes suffisamment indépendantes pour justifier leur agrégation sans pondération par ACP.

La matrice étendue 9×9 (ajout évolution population et part immigrés) confirme que ces deux variables démographiques restent faiblement corrélées aux composantes de l’indice (ρ max = 0.28 pour immigrés×ouvriers à Paris, 0.36 pour pop×prix à Marseille). Elles captent des dynamiques distinctes et justifient leur statut de variables supplémentaires dans l’ACP — projetées pour interprétation, sans contribution aux axes. À Marseille, la corrélation pop×prix (−0.357) est le signal le plus fort de toute la matrice étendue : la hausse des prix DVF s’accompagne d’un recul démographique, signe possible d’éviction résidentielle.

2 Sensibilité de l’indice

Un indice composite est aussi fiable que sa construction. Cette partie teste la robustesse de l’idx_gentri par trois approches complémentaires : le retrait successif de chaque composante (leave-one-out), la sensibilité au plafonnement des z-scores (cap ±3), et l’effet d’une pondération différenciée (double poids aux CSP). On examine ensuite les IRIS les plus gentrifiés et la stabilité temporelle T2 vs Long.

2.1 Test leave-one-out

Code
# &s &LEAVE_ONE_OUT - Retire chaque composante, recalcule idx, mesure Spearman ρ

leave_one_out <- function(df, z_cols, idx_col = "idxg_t2_ind") {
  full_idx <- df[[idx_col]]
  results <- list()
  for (i in seq_along(z_cols)) {
    cols_reduced <- z_cols[-i]
    mat_reduced  <- df[, unname(cols_reduced), drop = FALSE]
    idx_reduced  <- rowMeans(mat_reduced, na.rm = TRUE)
    # Normaliser 0-100 pour comparabilité
    idx_reduced  <- (idx_reduced - min(idx_reduced, na.rm = TRUE)) /
                    (max(idx_reduced, na.rm = TRUE) - min(idx_reduced, na.rm = TRUE)) * 100
    mask <- !is.na(full_idx) & !is.na(idx_reduced)
    rho <- cor(full_idx[mask], idx_reduced[mask], method = "spearman")
    results[[i]] <- tibble(
      composante_retiree = Z_LABELS[[names(z_cols)[i]]],
      n_comp_restantes   = length(cols_reduced),
      rho_spearman       = round(rho, 4),
      stable             = if_else(rho >= 0.90, "Oui", "Non")
    )
  }
  bind_rows(results)
}

loo_paris <- leave_one_out(df_paris, Z_T2)
loo_mars  <- leave_one_out(df_mars, Z_T2)

loo_all <- bind_rows(
  loo_paris |> mutate(perimetre = "Paris+PC"),
  loo_mars  |> mutate(perimetre = "Marseille")
)

# &e

reactable(loo_all,
  pagination = FALSE, compact = TRUE,
  groupBy = "perimetre",
  columns = list(
    perimetre          = colDef("Périmètre", width = 100),
    composante_retiree = colDef("Composante retirée", width = 220),
    n_comp_restantes   = colDef("N comp.", width = 70),
    rho_spearman       = colDef("ρ Spearman", width = 100,
                                style = function(val) {
                                  if (!is.na(val) && val < 0.90) list(color = "#ec008b", fontWeight = "bold")
                                  else list(color = col_green, fontWeight = "bold")
                                }),
    stable             = colDef("Stable (≥0.90)", width = 100,
                                style = function(val) {
                                  if (val == "Non") list(color = "#ec008b") else list(color = col_green)
                                })
  ))
Table 13

Leave-one-out : toutes les variantes au-dessus du seuil OCDE 0.90. Le revenu médian est la composante la plus influente (ρ=0.934 quand retirée) — son retrait modifie le plus le classement, ce qui est cohérent avec son rôle de marqueur transversal de l’élévation sociale. Aucune composante ne “tire” l’indice seule.

Astuce

Seuil OCDE : ρ ≥ 0.90 pour toutes les variantes = indice robuste, aucune composante ne tire seule le classement.

2.2 Robustesse du cap ±3

Code
# &s &CAP_ROBUSTNESS - Recalcul z-scores sans cap, comparaison ranking

recalc_idx_uncapped <- function(df, src_cols, invert_cols, z_cols_ref) {
  # Standardiser les colonnes source SANS cap
  z_uncapped <- data.frame(matrix(NA, nrow = nrow(df), ncol = length(src_cols)))
  names(z_uncapped) <- names(src_cols)

  for (nm in names(src_cols)) {
    col <- src_cols[[nm]]
    if (!col %in% names(df)) next
    vals <- df[[col]]
    mu <- mean(vals, na.rm = TRUE)
    sigma <- sd(vals, na.rm = TRUE)
    if (is.na(sigma) || sigma == 0) next
    z <- (vals - mu) / sigma
    if (nm %in% invert_cols) z <- -z
    z_uncapped[[nm]] <- z
  }

  # Moyenne simple → normaliser 0-100
  idx_raw <- rowMeans(z_uncapped, na.rm = TRUE)
  idx_norm <- (idx_raw - min(idx_raw, na.rm = TRUE)) /
              (max(idx_raw, na.rm = TRUE) - min(idx_raw, na.rm = TRUE)) * 100
  idx_norm
}

# Paris+PC
idx_uncapped_paris <- recalc_idx_uncapped(df_paris, SRC_COLS, INVERT, Z_T2)
mask_p <- !is.na(df_paris$idxg_t2_ind) & !is.na(idx_uncapped_paris)
rho_cap_paris <- cor(df_paris$idxg_t2_ind[mask_p], idx_uncapped_paris[mask_p], method = "spearman")

# Marseille
idx_uncapped_mars <- recalc_idx_uncapped(df_mars, SRC_COLS, INVERT, Z_T2)
mask_m <- !is.na(df_mars$idxg_t2_ind) & !is.na(idx_uncapped_mars)
rho_cap_mars <- cor(df_mars$idxg_t2_ind[mask_m], idx_uncapped_mars[mask_m], method = "spearman")

# &e

cap_summary <- tibble(
  Périmètre = c("Paris+PC", "Marseille"),
  `ρ capped vs uncapped` = c(round(rho_cap_paris, 4), round(rho_cap_mars, 4)),
  `Stable (≥0.99)` = if_else(c(rho_cap_paris, rho_cap_mars) >= 0.99, "Oui", "Non")
)

reactable(cap_summary, pagination = FALSE, compact = TRUE,
  columns = list(
    Périmètre = colDef(width = 120),
    `ρ capped vs uncapped` = colDef(width = 180,
      style = function(val) if (!is.na(val) && val >= 0.99) list(color = col_green, fontWeight = "bold")
                             else list(color = "#ec008b", fontWeight = "bold")),
    `Stable (≥0.99)` = colDef(width = 120)
  ))

Le cap ±3 modifie le classement plus que prévu (ρ=0.663 Paris+PC) — c’est le seul test qui échoue. Quelques IRIS avec des z-scores extrêmes (prix DVF, ratio prix/revenu) voient leur rang changer significativement. Ce n’est pas rédhibitoire : le cap protège contre les valeurs aberrantes, mais il faut documenter les IRIS concernés. Marseille est moins affecté (ρ=0.832) car sa distribution est plus ramassée.

2.3 Sensibilité à la pondération

Code
# Comparer 2 pondérations : uniforme (1/7) vs double PCS (cadres ×2, ouvriers ×2)

calc_idx_weighted <- function(df, z_cols, weights) {
  mat <- as.matrix(df[, unname(z_cols), drop = FALSE])
  # Appliquer poids (normaliser pour sommer à 1)
  w <- weights / sum(weights, na.rm = TRUE)
  idx_w <- mat %*% w
  # Normaliser 0-100
  idx_norm <- (idx_w - min(idx_w, na.rm = TRUE)) /
              (max(idx_w, na.rm = TRUE) - min(idx_w, na.rm = TRUE)) * 100
  as.numeric(idx_norm)
}

w_uniforme  <- rep(1, 7)
w_double_pcs <- c(cadres = 2, ouvriers = 2, revmed = 1, emmenrec = 1, prop = 1, px_dvf = 1, ratio_px = 1)

df_paris_ponder <- df_paris |>
  filter(!is.na(idxg_t2_ind)) |>
  mutate(
    idx_uniforme  = calc_idx_weighted(pick(all_of(unname(Z_T2))), Z_T2, w_uniforme),
    idx_double_pcs = calc_idx_weighted(pick(all_of(unname(Z_T2))), Z_T2, w_double_pcs)
  )

rho_ponder <- cor(df_paris_ponder$idx_uniforme, df_paris_ponder$idx_double_pcs,
                  use = "complete.obs", method = "spearman")

ggplot(df_paris_ponder, aes(x = idx_uniforme, y = idx_double_pcs)) +
  geom_point(alpha = 0.3, size = 0.8, color = col_cyan) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "#999999") +
  geom_smooth(method = "lm", se = FALSE, color = col_magenta, linewidth = 0.8) +
  annotate("text", x = 15, y = 90,
           label = sprintf("ρ Spearman = %.3f", rho_ponder),
           color = col_magenta, size = 4, fontface = "bold") +
  theme_urbn() +
  labs(
    title    = "Sensibilité à la pondération : uniforme vs double PCS",
    subtitle = sprintf("Paris+PC · %s IRIS · ρ = %.3f", format(nrow(df_paris_ponder), big.mark = " "), rho_ponder),
    x = "idx uniforme (1/7 chacune)", y = "idx double PCS (cadres ×2, ouvriers ×2)",
    caption  = make_source("RP INSEE 2016-2022", "Filosofi", "DVF")
  )

Le bilan des tests de sensibilité est contrasté. Le test leave-one-out confirme la stabilité structurelle : le retrait de n’importe quelle composante maintient la corrélation de rang au-dessus de 0.93 sur les deux périmètres (seuil OCDE : 0.90). La composante la plus influente est l’évolution du revenu médian (ρ=0.934 quand retirée) — retirer les autres composantes change encore moins le classement. La pondération double PCS (poids 2× sur cadres et ouvriers) produit un classement très corrélé à l’uniforme (ρ=0.964), ce qui valide le choix d’une pondération égale. En revanche, la comparaison capped/uncapped (ρ=0.663 Paris+PC, 0.832 Marseille) révèle que le cap ±3 modifie significativement le classement : quelques IRIS aux z-scores extrêmes changent de position quand on retire la censure. Ce résultat justifie le cap (il empêche les outliers de dominer) mais signale que le classement fin des IRIS extrêmes est sensible à ce choix méthodologique.

2.4 Les IRIS les plus gentrifiés

Code
# Top 20 IRIS Paris+PC par idx_gentri T2, avec z-scores des 7 composantes
top20 <- df_paris |>
  filter(!is.na(idxg_t2_ind)) |>
  arrange(desc(idxg_t2_ind)) |>
  head(20) |>
  select(code, libelle, libelle_com, dep, idxg_t2_ind, idxg_t2_ncomp,
         all_of(unname(Z_T2)))

# Renommer pour lisibilité
names(top20)[7:13] <- names(Z_T2)

reactable(top20,
  pagination = FALSE, compact = TRUE,
  defaultColDef = colDef(format = colFormat(digits = 1), width = 70),
  columns = list(
    code         = colDef("Code IRIS", width = 110, format = NULL),
    libelle      = colDef("IRIS", width = 180, format = NULL),
    libelle_com  = colDef("Commune", width = 130, format = NULL),
    dep          = colDef("Dept", width = 50, format = NULL),
    idxg_t2_ind  = colDef("idx", width = 60, style = list(fontWeight = "bold")),
    idxg_t2_ncomp = colDef("N comp", width = 60, format = colFormat(digits = 0))
  ),
  theme = reactableTheme(
    headerStyle = list(borderBottom = "2px solid #1696d2", fontSize = "0.8em"),
    cellStyle = list(fontSize = "0.8em")
  ))
Table 14

2.5 Gentrification complète (5/7 composantes positives)

Code
# Compter IRIS avec ≥ 5 composantes z > 0 (gentrification "complète")
df_complet <- df_paris |>
  filter(!is.na(idxg_t2_ind)) |>
  rowwise() |>
  mutate(
    n_positif = sum(c_across(all_of(unname(Z_T2))) > 0, na.rm = TRUE),
    n_dispo   = sum(!is.na(c_across(all_of(unname(Z_T2)))))
  ) |>
  ungroup()

n_complet_5 <- sum(df_complet$n_positif >= 5, na.rm = TRUE)
n_complet_7 <- sum(df_complet$n_positif == 7, na.rm = TRUE)
pct_complet_5 <- round(n_complet_5 / nrow(df_complet) * 100, 1)
pct_complet_7 <- round(n_complet_7 / nrow(df_complet) * 100, 1)

distrib_positif <- df_complet |>
  count(n_positif) |>
  mutate(pct = round(n / sum(n) * 100, 1))

reactable(distrib_positif,
  pagination = FALSE, compact = TRUE,
  columns = list(
    n_positif = colDef("N composantes z > 0", width = 180),
    n         = colDef("IRIS", width = 100, format = colFormat(separators = TRUE)),
    pct       = colDef("% du total", width = 100)
  ))

295 IRIS (20.9 %) présentent au moins 5 composantes positives sur 7 — gentrification convergente multi-dimensionnelle. 21 IRIS (1.5 %) affichent 7/7.

Le 93 est surreprésenté dans le top 20 % des IRIS gentrifiés (25 % du top pour ~13 % des IRIS du périmètre). 295 IRIS ont au moins 5/7 composantes positives (21 %), dont 21 avec les 7/7 — gentrification “complète” touchant simultanément CSP, revenus, immobilier et mobilité. Paris intra-muros fournit 57 % du top 20 % en volume mais le processus y est plus ancien et stabilisé. Le top 5 des IRIS les plus gentrifiés mêle Paris intra-muros et petite couronne : Javel 22 (Paris 15e, 74.2), Gabriel Péri (Clichy 92, 74.1), La Noue-Clos Français (Montreuil 93, 72.4), Secteur 4 (Saint-Ouen 93, 71.4) et Auteurs Pommiers (Pantin 93, 70.5). Trois des cinq sont en Seine-Saint-Denis, confirmant que le front de gentrification actif se situe dans le nord-est de la métropole. Parmi les 283 IRIS du top 20 %, Paris intra-muros domine en volume (57 %) mais le 93 est surreprésenté (25 % du top pour environ 13 % des IRIS du périmètre). Le profil de gentrification “complète” (7/7 composantes positives) ne concerne que 21 IRIS (1.5 %) — le processus reste partiel dans l’écrasante majorité des cas, activant typiquement 3 à 5 dimensions sur sept.

2.6 Comparaison T2 vs Long période

L’indice est calculé sur deux temporalités : T2 (2016-2022, période courte) et Long (2011-2022, période longue). Si la gentrification est un processus persistant, les deux classements devraient être fortement corrélés. L’écart entre les deux révèle les IRIS où le processus s’accélère ou ralentit.

Code
# Scatter T2 (16→22) vs Long (11→22) pour Paris+PC
df_t2long <- df_paris |>
  filter(!is.na(idxg_t2_ind), !is.na(idxg_ind))

rho_t2long <- cor(df_t2long$idxg_t2_ind, df_t2long$idxg_ind,
                  method = "spearman", use = "complete.obs")

ggplot(df_t2long, aes(x = idxg_ind, y = idxg_t2_ind, color = dep)) +
  geom_point(alpha = 0.4, size = 1.2) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "#999999") +
  scale_color_manual(values = c("75" = col_cyan, "92" = col_green,
                                 "93" = col_yellow, "94" = col_magenta)) +
  annotate("text", x = 15, y = 90,
           label = sprintf("ρ Spearman = %.3f\n%s IRIS",
                           rho_t2long, format(nrow(df_t2long), big.mark = " ")),
           size = 3.5, color = "gray30") +
  # Quadrants d'interprétation
  annotate("text", x = 85, y = 25, label = "Stabilisée\n(Long forte, T2 faible)",
           size = 2.5, color = "#999", fontface = "italic") +
  annotate("text", x = 25, y = 85, label = "Émergente\n(Long faible, T2 forte)",
           size = 2.5, color = "#999", fontface = "italic") +
  theme_urbn() +
  labs(
    title    = "Gentrification Long (11→22) vs T2 (16→22) — Paris+PC",
    subtitle = sprintf("ρ Spearman = %.3f · Au-dessus de la diagonale = accélération récente",
                       rho_t2long),
    x = "idx_gentri Long (2011-2022)", y = "idx_gentri T2 (2016-2022)",
    color = "Dept",
    caption  = make_source("RP INSEE 2011-2022", "Filosofi", "DVF")
  )

2.7 Trajectoires émergentes et stabilisées

Code
# Identifier IRIS divergents : accélération (T2 >> Long) ou décélération (Long >> T2)
df_traj <- df_t2long |>
  mutate(
    delta    = idxg_t2_ind - idxg_ind,
    trajectoire = case_when(
      delta > 15 & idxg_t2_ind > 60  ~ "Émergente (accélération récente)",
      delta < -15 & idxg_ind > 60    ~ "Stabilisée (décélération récente)",
      idxg_t2_ind > 70 & idxg_ind > 70 ~ "Persistante (forte les 2 périodes)",
      TRUE ~ "Standard"
    )
  )

traj_summary <- df_traj |>
  count(trajectoire) |>
  mutate(pct = round(n / sum(n) * 100, 1)) |>
  arrange(desc(n))

reactable(traj_summary,
  pagination = FALSE, compact = TRUE,
  columns = list(
    trajectoire = colDef("Trajectoire", width = 300),
    n           = colDef("IRIS", width = 100, format = colFormat(separators = TRUE)),
    pct         = colDef("% total", width = 80)
  ))
Table 15

T2 vs Long : ρ=0.655 — la gentrification est un processus persistant mais pas figé. La corrélation modérée confirme que les territoires gentrifiés en période courte le sont globalement aussi en longue période, mais avec 57 % de variance non partagée — des dynamiques locales distinctes entre les deux temporalités. 5 IRIS “émergents” (gentrification récente absente en longue période) et 7 “stabilisés” (gentrification en longue période qui faiblit en T2).

La comparaison T2 (2016-2022) vs longue période (2011-2022) produit un ρ de 0.655 sur 1 411 IRIS — corrélation modérée qui confirme la persistance globale du processus de gentrification, mais avec 57 % de variance non partagée entre les deux temporalités. 5 IRIS sont identifiés comme “émergents” (gentrification récente, absente en longue période) — principalement Paris intra-muros (3 IRIS) avec 1 en 93 et 1 en 94. 7 IRIS sont “stabilisés” (forte gentrification en longue période, retombée en T2). L’écart entre ρ_T2vsLong (0.655) et ρ_cap (0.663) est remarquable : le cap ±3 et le changement de temporalité perturbent le classement dans des proportions comparables. La gentrification à l’IRIS est un processus continu et diffus, mais pas linéaire — les trajectoires individuelles fluctuent entre les sous-périodes.

2.8 Comparaison T1 vs T2 — accélération ou décélération ?

La période T1 (2011→2016) utilise 6 composantes (pas de DVF prix, non disponible avant 2016). Comparer T1 et T2 permet en théorie d’identifier les IRIS qui accélèrent ou décélèrent entre les deux sous-périodes. Attention cependant : T1 et T2 diffèrent par leur composition (6 vs 7 variables) et les z-scores sont restandardisés par période — l’interprétation des écarts doit tenir compte de ces biais méthodologiques.

Code
# Scatter T1 (11→16) vs T2 (16→22) — Paris+PC
df_t1t2_paris <- df_paris |>
  filter(!is.na(idxg_t1_ind), !is.na(idxg_t2_ind))

rho_t1t2_paris <- cor(df_t1t2_paris$idxg_t1_ind, df_t1t2_paris$idxg_t2_ind,
                      method = "spearman", use = "complete.obs")

p_t1t2_paris <- ggplot(df_t1t2_paris, aes(x = idxg_t1_ind, y = idxg_t2_ind, color = dep)) +
  geom_point(alpha = 0.4, size = 1.2) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "#999999") +
  scale_color_manual(values = c("75" = col_cyan, "92" = col_green,
                                 "93" = col_yellow, "94" = col_magenta)) +
  annotate("text", x = 15, y = 90,
           label = sprintf("ρ = %.3f\n%s IRIS", rho_t1t2_paris,
                           format(nrow(df_t1t2_paris), big.mark = " ")),
           size = 3.5, color = "gray30") +
  annotate("text", x = 80, y = 25, label = "Décélération\n(T1 forte, T2 faible)",
           size = 2.5, color = "#999", fontface = "italic") +
  annotate("text", x = 25, y = 80, label = "Accélération\n(T1 faible, T2 forte)",
           size = 2.5, color = "#999", fontface = "italic") +
  theme_urbn() +
  labs(title = "Paris+PC — T1 (11→16) vs T2 (16→22)",
       subtitle = sprintf("ρ Spearman = %.3f · Diagonale = même rythme",
                          rho_t1t2_paris),
       x = "idx_gentri T1 (2011-2016)", y = "idx_gentri T2 (2016-2022)",
       color = "Dept",
       caption = make_source("RP INSEE 2011-2022", "Filosofi", "DVF"))

# Scatter T1 vs T2 — Marseille
df_t1t2_mars <- df_mars |>
  filter(!is.na(idxg_t1_ind), !is.na(idxg_t2_ind))

rho_t1t2_mars <- cor(df_t1t2_mars$idxg_t1_ind, df_t1t2_mars$idxg_t2_ind,
                     method = "spearman", use = "complete.obs")

p_t1t2_mars <- ggplot(df_t1t2_mars, aes(x = idxg_t1_ind, y = idxg_t2_ind)) +
  geom_point(alpha = 0.4, size = 1.2, color = col_magenta) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "#999999") +
  annotate("text", x = 15, y = 90,
           label = sprintf("ρ = %.3f\n%s IRIS", rho_t1t2_mars,
                           format(nrow(df_t1t2_mars), big.mark = " ")),
           size = 3.5, color = "gray30") +
  annotate("text", x = 80, y = 25, label = "Décélération",
           size = 2.5, color = "#999", fontface = "italic") +
  annotate("text", x = 25, y = 80, label = "Accélération",
           size = 2.5, color = "#999", fontface = "italic") +
  theme_urbn() +
  labs(title = "Marseille — T1 (11→16) vs T2 (16→22)",
       subtitle = sprintf("ρ Spearman = %.3f", rho_t1t2_mars),
       x = "idx_gentri T1 (2011-2016)", y = "idx_gentri T2 (2016-2022)",
       caption = make_source("RP INSEE 2011-2022", "Filosofi", "DVF"))

p_t1t2_paris + p_t1t2_mars +
  plot_layout(guides = "collect") &
  theme(legend.position = "bottom")

2.9 IRIS en accélération et décélération

Code
# Identifier les IRIS qui changent de rythme entre T1 et T2
SEUIL_DELTA <- 10  # écart seuil en points d'indice

df_dyn_paris <- df_t1t2_paris |>
  mutate(
    delta_t1t2 = idxg_t2_ind - idxg_t1_ind,
    dynamique = case_when(
      delta_t1t2 > SEUIL_DELTA  ~ "Accélération (T2 >> T1)",
      delta_t1t2 < -SEUIL_DELTA ~ "Décélération (T1 >> T2)",
      TRUE ~ "Stable (|Δ| ≤ 10 pts)"
    )
  )

df_dyn_mars <- df_t1t2_mars |>
  mutate(
    delta_t1t2 = idxg_t2_ind - idxg_t1_ind,
    dynamique = case_when(
      delta_t1t2 > SEUIL_DELTA  ~ "Accélération (T2 >> T1)",
      delta_t1t2 < -SEUIL_DELTA ~ "Décélération (T1 >> T2)",
      TRUE ~ "Stable (|Δ| ≤ 10 pts)"
    )
  )

dyn_summary <- bind_rows(
  df_dyn_paris |> count(dynamique) |> mutate(perimetre = "Paris+PC"),
  df_dyn_mars |> count(dynamique) |> mutate(perimetre = "Marseille")
) |>
  group_by(perimetre) |>
  mutate(pct = round(n / sum(n) * 100, 1)) |>
  ungroup() |>
  select(perimetre, dynamique, n, pct) |>
  arrange(perimetre, desc(n))

reactable(dyn_summary,
  pagination = FALSE, compact = TRUE,
  groupBy = "perimetre",
  columns = list(
    perimetre = colDef("Périmètre", width = 120),
    dynamique = colDef("Dynamique T1→T2", width = 250),
    n         = colDef("IRIS", width = 80, format = colFormat(separators = TRUE)),
    pct       = colDef("% total", width = 80)
  ))
Table 16
Code
# Top 15 accélérations et décélérations — Paris+PC
top_accel <- df_dyn_paris |>
  filter(dynamique == "Accélération (T2 >> T1)") |>
  arrange(desc(delta_t1t2)) |>
  head(15) |>
  select(code, libelle, libelle_com, dep, idxg_t1_ind, idxg_t2_ind, delta_t1t2)

top_decel <- df_dyn_paris |>
  filter(dynamique == "Décélération (T1 >> T2)") |>
  arrange(delta_t1t2) |>
  head(15) |>
  select(code, libelle, libelle_com, dep, idxg_t1_ind, idxg_t2_ind, delta_t1t2)

top_ad <- bind_rows(
  top_accel |> mutate(type = "Accélération"),
  top_decel |> mutate(type = "Décélération")
)

reactable(top_ad,
  pagination = FALSE, compact = TRUE,
  groupBy = "type",
  defaultColDef = colDef(format = colFormat(digits = 1)),
  columns = list(
    type        = colDef("Dynamique", width = 120, format = NULL),
    code        = colDef("IRIS", width = 100, format = NULL),
    libelle     = colDef("Libellé IRIS", width = 200, format = NULL),
    libelle_com = colDef("Commune", width = 150, format = NULL),
    dep         = colDef("Dept", width = 50, format = NULL),
    idxg_t1_ind = colDef("idx T1", width = 70),
    idxg_t2_ind = colDef("idx T2", width = 70),
    delta_t1t2  = colDef("Δ (T2−T1)", width = 90)
  ))
Table 17

Résultat inattendu : T1 et T2 sont quasi décorrélés (ρ ≈ 0,004 Paris, ρ ≈ 0,055 Marseille). Le classement des IRIS en T1 ne prédit pas du tout le classement en T2. Près de la moitié des IRIS changent de plus de 10 points entre les deux sous-périodes (193 accélérations, 189 décélérations à Paris — distribution quasi symétrique). Trois hypothèses explicatives : (1) T1 n’utilise que 6 composantes (pas de DVF prix) — la 7ᵉ composante ajoutée en T2 redistribue les cartes ; (2) les z-scores sont standardisés au sein de chaque période — un IRIS “haut” en T1 peut être “moyen” en T2 si le référentiel change ; (3) la gentrification est un processus spatialement mobile — les quartiers actifs en 2011-2016 ne sont pas les mêmes qu’en 2016-2022. Ce résultat contraste fortement avec T2 vs Long (ρ = 0,655) qui lisse les deux sous-périodes en une seule fenêtre. La quasi-absence de corrélation T1/T2 est un signal d’alerte méthodologique : comparer des indices composites de périodes différentes est délicat quand la composition change (6 vs 7 variables).

3 ACP — Validation structurelle

L’ACP ne sert pas ici à construire l’indice – il est déjà construit par agrégation de z-scores. Elle sert à vérifier que les 7 composantes ont une structure interne cohérente : les variables captent-elles un gradient commun de gentrification, ou sont-elles hétérogènes ? On applique l’ACP séparément sur Paris+PC et Marseille car leurs structures socio-urbaines sont très différentes, comme le cadrage l’a montré.

L’ACP ci-dessous porte sur les 7 variables brutes d’évolution qui alimentent l’indice — avant toute transformation (z-scores, cap, inversion). Deux variables supplémentaires (évolution population, évolution part immigrés) sont projetées sur les axes sans contribuer à leur construction (quanti.sup). L’objectif est de vérifier la cohérence dimensionnelle et de situer les dynamiques démographiques par rapport au gradient de gentrification. On ne retient que les IRIS disposant des 7 composantes et d’au moins 500 habitants (idxg_ncomp = 7, pop >= 500).

Tests pré-ACP : KMO et Bartlett

Code
# &s &KMO_BARTLETT - Tests pré-ACP KMO et Bartlett

library(psych)

mat_kmo_paris <- df_acp_paris |> select(all_of(ACP_VARS))
colnames(mat_kmo_paris) <- ACP_LABELS
mat_kmo_mars <- df_acp_mars |> select(all_of(ACP_VARS))
colnames(mat_kmo_mars) <- ACP_LABELS

kmo_paris <- KMO(cor(mat_kmo_paris))
kmo_mars  <- KMO(cor(mat_kmo_mars))
bart_paris <- cortest.bartlett(cor(mat_kmo_paris), n = nrow(mat_kmo_paris))
bart_mars  <- cortest.bartlett(cor(mat_kmo_mars), n = nrow(mat_kmo_mars))

# Tableau KMO par variable
kmo_detail <- tibble(
  variable = ACP_LABELS,
  MSAi_Paris = round(kmo_paris$MSAi, 3),
  MSAi_Marseille = round(kmo_mars$MSAi, 3)
)

reactable(kmo_detail,
  pagination = FALSE, compact = TRUE,
  columns = list(
    variable = colDef("Variable", width = 120),
    MSAi_Paris = colDef("KMO Paris", width = 100, align = "center",
      style = function(val) {
        if (!is.na(val) && val < 0.50) list(color = "#dc2626", fontWeight = "bold")
        else if (!is.na(val) && val >= 0.60) list(color = "#16a34a") else list()
      }),
    MSAi_Marseille = colDef("KMO Marseille", width = 120, align = "center",
      style = function(val) {
        if (!is.na(val) && val < 0.50) list(color = "#dc2626", fontWeight = "bold")
        else if (!is.na(val) && val >= 0.60) list(color = "#16a34a") else list()
      })
  ),
  theme = reactableTheme(headerStyle = list(fontSize = "11px", borderBottom = "2px solid #1696d2")))
Code
# &e

KMO global : Paris = 0.503 | Marseille = 0.557

Bartlett : Paris χ² = 539.4 (p < 0.001) | Marseille χ² = 159.4 (p < 0.001)

KMO légèrement sous le seuil conventionnel de 0.50 à Paris (0.497), acceptable à Marseille (0.547). Ceci est cohérent avec un indice composite multidimensionnel — l’ACP ne cherche pas ici un facteur unique mais explore la structure des corrélations entre 7 dimensions distinctes de gentrification. Les variables DVF (px_rev MSAi ≈ 0.35) captent une dimension séparée du gradient CSP, ce qui est attendu. Le test de Bartlett confirme que les corrélations observées ne sont pas dues au hasard (p < 0.001 dans les deux périmètres).

Paris+PC — 1100 IRIS à 7 composantes

Cercle des corrélations (actives + supplémentaires)

Biplot — IRIS par département

Coord + cos2 + contrib — Dim1 et Dim2

Code
# &s &ACP1_DETAIL - Tableau coord/cos2/contrib Paris

detail_paris <- data.frame(
  variable = rownames(var_paris$coord),
  coord_dim1 = round(var_paris$coord[, 1], 3),
  coord_dim2 = round(var_paris$coord[, 2], 3),
  cos2_dim1 = round(var_paris$cos2[, 1], 3),
  cos2_dim2 = round(var_paris$cos2[, 2], 3),
  contrib_dim1 = round(var_paris$contrib[, 1], 1),
  contrib_dim2 = round(var_paris$contrib[, 2], 1)
) |> arrange(desc(abs(coord_dim1)))

# Ajouter sup vars
detail_sup_paris <- data.frame(
  variable = rownames(sup_coord),
  coord_dim1 = round(sup_coord[, 1], 3),
  coord_dim2 = round(sup_coord[, 2], 3),
  cos2_dim1 = round(pca_paris$quanti.sup$cos2[, 1], 3),
  cos2_dim2 = round(pca_paris$quanti.sup$cos2[, 2], 3),
  contrib_dim1 = NA_real_,
  contrib_dim2 = NA_real_
)

detail_all_paris <- bind_rows(
  detail_paris |> mutate(type = "active"),
  detail_sup_paris |> mutate(type = "suppl.")
)

reactable(detail_all_paris,
  pagination = FALSE, compact = TRUE,
  defaultColDef = colDef(format = colFormat(digits = 3), width = 85, align = "center"),
  columns = list(
    variable = colDef("Variable", width = 120, align = "left",
      style = function(val, index) {
        if (detail_all_paris$type[index] == "suppl.") list(fontStyle = "italic", color = "#9333ea")
        else list(fontWeight = "bold")
      }),
    type = colDef("Type", width = 60, style = function(val) {
      if (val == "suppl.") list(color = "#9333ea") else list()
    }),
    contrib_dim1 = colDef("contrib D1", cell = function(val) {
      if (is.na(val)) "--" else sprintf("%.1f%%", val)
    }),
    contrib_dim2 = colDef("contrib D2", cell = function(val) {
      if (is.na(val)) "--" else sprintf("%.1f%%", val)
    })
  ),
  theme = reactableTheme(headerStyle = list(fontSize = "10px", borderBottom = "2px solid #1696d2")))
Code
# &e

L’ACP Paris+PC confirme un indice multidimensionnel, pas unidimensionnel (Dim1 = 24.4 %, 3 axes Kaiser, KMO = 0.497). Le premier axe oppose nettement cadres (+0.85) et propriétaires (+0.68) aux ouvriers (−0.65) — le gradient socioprofessionnel de la gentrification. Mais les prix DVF et le taux de pauvreté forment des dimensions séparées, ce qui valide le choix d’agrégation par moyenne simple plutôt que par pondération ACP. Les variables supplémentaires (tirets violets) se projettent négativement sur cet axe : évolution immigrés (−0.28) et évolution population (−0.15). Les IRIS qui montent en cadres perdent des immigrés — un signal de remplacement démographique cohérent avec la littérature sur la gentrification.

Marseille — 266 IRIS à 7 composantes

Cercle des corrélations (actives + supplémentaires)

Biplot — IRIS Marseille

Coord + cos2 + contrib — Dim1 et Dim2

Code
# &s &ACP1_MARS_DETAIL - Tableau coord/cos2/contrib Marseille

detail_mars <- data.frame(
  variable = rownames(var_mars$coord),
  coord_dim1 = round(var_mars$coord[, 1], 3),
  coord_dim2 = round(var_mars$coord[, 2], 3),
  cos2_dim1 = round(var_mars$cos2[, 1], 3),
  cos2_dim2 = round(var_mars$cos2[, 2], 3),
  contrib_dim1 = round(var_mars$contrib[, 1], 1),
  contrib_dim2 = round(var_mars$contrib[, 2], 1)
) |> arrange(desc(abs(coord_dim1)))

detail_sup_mars <- data.frame(
  variable = rownames(sup_coord_m),
  coord_dim1 = round(sup_coord_m[, 1], 3),
  coord_dim2 = round(sup_coord_m[, 2], 3),
  cos2_dim1 = round(pca_mars$quanti.sup$cos2[, 1], 3),
  cos2_dim2 = round(pca_mars$quanti.sup$cos2[, 2], 3),
  contrib_dim1 = NA_real_,
  contrib_dim2 = NA_real_
)

detail_all_mars <- bind_rows(
  detail_mars |> mutate(type = "active"),
  detail_sup_mars |> mutate(type = "suppl.")
)

reactable(detail_all_mars,
  pagination = FALSE, compact = TRUE,
  defaultColDef = colDef(format = colFormat(digits = 3), width = 85, align = "center"),
  columns = list(
    variable = colDef("Variable", width = 120, align = "left",
      style = function(val, index) {
        if (detail_all_mars$type[index] == "suppl.") list(fontStyle = "italic", color = "#9333ea")
        else list(fontWeight = "bold")
      }),
    type = colDef("Type", width = 60, style = function(val) {
      if (val == "suppl.") list(color = "#9333ea") else list()
    }),
    contrib_dim1 = colDef("contrib D1", cell = function(val) {
      if (is.na(val)) "--" else sprintf("%.1f%%", val)
    }),
    contrib_dim2 = colDef("contrib D2", cell = function(val) {
      if (is.na(val)) "--" else sprintf("%.1f%%", val)
    })
  ),
  theme = reactableTheme(headerStyle = list(fontSize = "10px", borderBottom = "2px solid #1696d2")))
Code
# &e

Marseille révèle une structure factorielle différente de Paris+PC (Dim1 = 26.8 %, KMO = 0.547). Le Dim1 est porté par les prix DVF — évolution des prix (+0.68) et ratio prix/revenu (+0.50) — et la part de propriétaires (+0.65), tandis que la composante CSP n’arrive qu’en second plan (cadres +0.42, ouvriers −0.45). À l’inverse, à Paris+PC c’est le gradient socioprofessionnel qui structure le premier axe. Les emménagés récents s’inversent (−0.52 à Marseille vs +0.29 à Paris) : à Marseille, les quartiers en hausse de prix voient leur mobilité résidentielle chuter. Les variables supplémentaires confirment : l’évolution population se projette à −0.37 sur Dim1 (cos² = 0.133, 6× Paris) — la hausse des prix s’accompagne nettement d’une perte de population, signe d’éviction résidentielle.

3.1 Cercles corrélation côte à côte

3.2 Biplots côte à côte

4 ACP+CAH — Typologie IRIS

L’ACP a révélé des structures factorielles distinctes entre les deux périmètres. On projette maintenant les IRIS sur les axes principaux et on les regroupe par classification ascendante hiérarchique (Ward.D2) pour identifier des profils-types de gentrification. La silhouette guide le choix du nombre de clusters, la caractérisation automatique (catdes) identifie les variables les plus discriminantes par profil. On sépare les deux périmètres car leurs structures factorielles ne sont pas comparables.

4.1 Choix du nombre de clusters

Grille silhouette (axes x k)

Code
# &s &SILHOUETTE_GRID - Grille silhouette nDim x k

sil_grid <- function(pca_obj, label) {
  results <- list()
  for (nd in 2:min(5, ncol(pca_obj$ind$coord))) {
    for (k in 2:5) {
      sc <- pca_obj$ind$coord[, 1:nd]
      hc <- hclust(dist(sc), method = "ward.D2")
      cl <- cutree(hc, k = k)
      s <- mean(silhouette(cl, dist(sc))[, 3])
      results[[length(results) + 1]] <- tibble(
        perimetre = label, n_axes = nd, k = k, silhouette = round(s, 3))
    }
  }
  bind_rows(results)
}

grid_paris <- sil_grid(pca_paris, "Paris+PC")
grid_mars  <- sil_grid(pca_mars, "Marseille")
grid_all   <- bind_rows(grid_paris, grid_mars)

# Format pivot pour lisibilite
grid_wide <- grid_all |>
  mutate(cell = sprintf("%.3f", silhouette)) |>
  select(perimetre, n_axes, k, cell) |>
  pivot_wider(names_from = k, values_from = cell, names_prefix = "k=")

reactable(grid_wide,
  pagination = FALSE, compact = TRUE,
  groupBy = "perimetre", defaultExpanded = TRUE,
  columns = list(
    perimetre = colDef("Perimetre", width = 100),
    n_axes = colDef("Axes", width = 60)
  ),
  defaultColDef = colDef(width = 70, align = "center"),
  theme = reactableTheme(headerStyle = list(fontSize = "11px", borderBottom = "2px solid #1696d2")))
Code
# Best config
best_paris <- grid_paris |> arrange(desc(silhouette)) |> head(1)
best_mars  <- grid_mars  |> arrange(desc(silhouette)) |> head(1)

# &e

Meilleure silhouette : Paris = 0.306 (axes=2, k=2) | Marseille = 0.339 (axes=2, k=3)

Code
# &s &SILHOUETTE - Silhouette kmeans + Elbow pour k=2..8

p_sil_paris <- factoextra::fviz_nbclust(scores_paris, kmeans, method = "silhouette",
                                          k.max = 8) +
  labs(title = "Silhouette -- Paris+PC") + theme_minimal(base_size = 12)

p_sil_mars <- factoextra::fviz_nbclust(scores_mars, kmeans, method = "silhouette",
                                         k.max = 8) +
  labs(title = "Silhouette -- Marseille") + theme_minimal(base_size = 12)

p_sil_paris + p_sil_mars

Code
# &e
Code
# &s &DENDROGRAMME - Ward.D2 avec overlay k=3,4,5

par(mfrow = c(1, 2))

hc_paris <- hclust(dist(scores_paris), method = "ward.D2")
plot(hc_paris, labels = FALSE, main = sprintf("Dendrogramme Paris+PC (%d IRIS)", n_acp_paris),
     xlab = "", sub = "Ward.D2", cex.main = 1)
rect.hclust(hc_paris, k = 3, border = "#55b748")
rect.hclust(hc_paris, k = 4, border = "#dc2626")
rect.hclust(hc_paris, k = 5, border = "#1696d2")
legend("topright", legend = c("k=3", "k=4", "k=5"),
       col = c("#55b748", "#dc2626", "#1696d2"), lty = 1, lwd = 2, cex = 0.8)

hc_mars <- hclust(dist(scores_mars), method = "ward.D2")
plot(hc_mars, labels = FALSE, main = sprintf("Dendrogramme Marseille (%d IRIS)", n_acp_mars),
     xlab = "", sub = "Ward.D2", cex.main = 1)
rect.hclust(hc_mars, k = 3, border = "#55b748")
rect.hclust(hc_mars, k = 4, border = "#dc2626")
rect.hclust(hc_mars, k = 5, border = "#1696d2")

Code
par(mfrow = c(1, 1))

# &e

4.2 HCPC — Classification finale

4.3 Plan factoriel par cluster

Code
# &s &CAH_BIPLOT - Individus colorés par cluster avec labels dep-commune

# Paris : biplot custom avec clusters et labels top 20
ind_p <- get_pca_ind(pca_paris)
df_cah_paris <- data.frame(
  label = df_acp_paris$iris_label,
  x = ind_p$coord[, 1], y = ind_p$coord[, 2],
  cluster = df_acp_paris$cluster,
  contrib = rowMeans(ind_p$contrib[, 1:2])
)

top20_cah_p <- sort(df_cah_paris$contrib, decreasing = TRUE)[min(20, nrow(df_cah_paris))]
df_cah_top_p <- df_cah_paris[df_cah_paris$contrib >= top20_cah_p, ]

# Barycentres
bary_p <- df_cah_paris |> group_by(cluster) |>
  summarise(x = mean(x), y = mean(y), n = n(), .groups = "drop") |>
  mutate(label = sprintf("C%s (n=%d)", cluster, n))

p1 <- ggplot() +
  geom_hline(yintercept = 0, linetype = "dashed", color = "gray80") +
  geom_vline(xintercept = 0, linetype = "dashed", color = "gray80") +
  geom_point(data = df_cah_paris, aes(x = x, y = y, color = cluster),
             size = 1.8, alpha = 0.5) +
  geom_point(data = bary_p, aes(x = x, y = y, color = cluster),
             size = 5, shape = 18) +
  geom_text_repel(data = bary_p, aes(x = x, y = y, label = label, color = cluster),
                  fontface = "bold", size = 3.5, show.legend = FALSE) +
  geom_text_repel(data = df_cah_top_p,
                  aes(x = x, y = y, label = label, color = cluster),
                  size = 2.8, fontface = "italic", max.overlaps = 25,
                  segment.color = "gray70", show.legend = FALSE) +
  scale_color_manual(values = PAL_QUALI, name = "Cluster") +
  coord_fixed() +
  labs(x = xlab_p, y = ylab_p,
       title = sprintf("Paris+PC -- %d clusters (sil=%.2f)", K_PARIS, sil_mean_paris),
       subtitle = "Top 20 contributeurs etiquetes (dep-commune)") +
  theme_minimal(base_size = 13) +
  theme(legend.position = "right", plot.title = element_text(size = 13, face = "bold"),
        plot.subtitle = element_text(size = 10, color = "gray40"))

# Marseille
ind_m <- get_pca_ind(pca_mars)
df_cah_mars <- data.frame(
  label = df_acp_mars$iris_label,
  x = ind_m$coord[, 1], y = ind_m$coord[, 2],
  cluster = df_acp_mars$cluster,
  contrib = rowMeans(ind_m$contrib[, 1:2])
)

top20_cah_m <- sort(df_cah_mars$contrib, decreasing = TRUE)[min(20, nrow(df_cah_mars))]
df_cah_top_m <- df_cah_mars[df_cah_mars$contrib >= top20_cah_m, ]

bary_m <- df_cah_mars |> group_by(cluster) |>
  summarise(x = mean(x), y = mean(y), n = n(), .groups = "drop") |>
  mutate(label = sprintf("C%s (n=%d)", cluster, n))

p2 <- ggplot() +
  geom_hline(yintercept = 0, linetype = "dashed", color = "gray80") +
  geom_vline(xintercept = 0, linetype = "dashed", color = "gray80") +
  geom_point(data = df_cah_mars, aes(x = x, y = y, color = cluster),
             size = 1.8, alpha = 0.5) +
  geom_point(data = bary_m, aes(x = x, y = y, color = cluster),
             size = 5, shape = 18) +
  geom_text_repel(data = bary_m, aes(x = x, y = y, label = label, color = cluster),
                  fontface = "bold", size = 3.5, show.legend = FALSE) +
  geom_text_repel(data = df_cah_top_m,
                  aes(x = x, y = y, label = label, color = cluster),
                  size = 2.8, fontface = "italic", max.overlaps = 25,
                  segment.color = "gray70", show.legend = FALSE) +
  scale_color_manual(values = PAL_QUALI, name = "Cluster") +
  coord_fixed() +
  labs(x = xlab_m, y = ylab_m,
       title = sprintf("Marseille -- %d clusters (sil=%.2f)", K_MARS, sil_mean_mars),
       subtitle = "Top 20 contributeurs etiquetes (dep-commune)") +
  theme_minimal(base_size = 13) +
  theme(legend.position = "right", plot.title = element_text(size = 13, face = "bold"),
        plot.subtitle = element_text(size = 10, color = "gray40"))

p1 + p2

Code
# &e

4.4 Profils des clusters (z-scores)

Table 18
Code
# &s &PROFILS_CLUSTERS - Z-score moyen par cluster (ecart-type a la moyenne globale)

build_profil_zscore <- function(df, vars, labels, cluster_col = "cluster") {
  # Centrer-reduire chaque variable sur l'ensemble du perimetre
  df_z <- df |>
    mutate(across(all_of(vars), ~scale(.x)[,1]))

  # Moyenne des z-scores par cluster
  profil <- df_z |>
    group_by(.data[[cluster_col]]) |>
    summarise(n = n(), across(all_of(vars), ~round(mean(.x, na.rm = TRUE), 2)), .groups = "drop")

  names(profil)[names(profil) %in% vars] <- labels[match(names(profil)[names(profil) %in% vars], vars)]
  profil
}

profil_paris <- build_profil_zscore(df_acp_paris, ACP_VARS, ACP_LABELS)
profil_mars  <- build_profil_zscore(df_acp_mars, ACP_VARS, ACP_LABELS)

# Style : vert si positif, rouge si negatif, intensite selon z-score
zscore_style <- function(value) {
  if (is.na(value) || !is.numeric(value)) return(list())
  bg <- if (value > 0.5) "#d4edda" else if (value < -0.5) "#f8d7da"
        else if (value > 0.2) "#e8f5e9" else if (value < -0.2) "#fce4ec" else "#f8f9fa"
  fw <- if (abs(value) > 0.5) "bold" else "normal"
  list(background = bg, fontWeight = fw)
}

Paris+PC — 4 clusters

Code
cols_paris <- list(
  cluster = colDef("Cluster", width = 70, style = list(fontWeight = "bold")),
  n = colDef("N IRIS", width = 70, format = colFormat(separators = TRUE))
)
for (lab in ACP_LABELS) {
  cols_paris[[lab]] <- colDef(name = lab, width = 90, align = "center",
                               format = colFormat(digits = 2),
                               style = zscore_style)
}

reactable(profil_paris, columns = cols_paris,
          pagination = FALSE, compact = TRUE, bordered = TRUE,
          theme = reactableTheme(headerStyle = list(fontSize = "10px", borderBottom = "2px solid #1696d2")))
Table 19

Marseille — 4 clusters

Code
cols_mars <- list(
  cluster = colDef("Cluster", width = 70, style = list(fontWeight = "bold")),
  n = colDef("N IRIS", width = 70, format = colFormat(separators = TRUE))
)
for (lab in ACP_LABELS) {
  cols_mars[[lab]] <- colDef(name = lab, width = 90, align = "center",
                              format = colFormat(digits = 2),
                              style = zscore_style)
}

reactable(profil_mars, columns = cols_mars,
          pagination = FALSE, compact = TRUE, bordered = TRUE,
          theme = reactableTheme(headerStyle = list(fontSize = "10px", borderBottom = "2px solid #1696d2")))
Table 20

4.5 Caractérisation automatique (catdes)

Code
# &s &CATDES - Variables discriminantes par cluster (v-test)

# Paris+PC
df_catdes_paris <- df_acp_paris |>
  select(cluster, all_of(ACP_VARS)) |>
  setNames(c("cluster", ACP_LABELS)) |>
  mutate(cluster = factor(cluster))

carac_paris <- catdes(df_catdes_paris, num.var = 1, proba = 0.05)

# Eta2 via ANOVA manuelle (catdes$quanti.var peut etre vide selon version)
eta2_paris <- purrr::map_dfr(ACP_LABELS, function(v) {
  aov_res <- aov(as.formula(paste0("`", v, "` ~ cluster")), data = df_catdes_paris)
  ss <- summary(aov_res)[[1]]
  tibble(variable = v, Eta2 = round(ss$`Sum Sq`[1] / sum(ss$`Sum Sq`), 3),
         F = round(ss$`F value`[1], 1), p = ss$`Pr(>F)`[1])
}) |> arrange(desc(Eta2))

reactable(eta2_paris,
  pagination = FALSE, compact = TRUE,
  columns = list(
    variable = colDef("Variable", width = 150),
    Eta2     = colDef("Eta2", width = 80, style = function(val) {
      if (!is.na(val) && val > 0.10) list(fontWeight = "bold", color = "#1696d2") else list()
    }),
    F = colDef("F", width = 70, format = colFormat(digits = 1)),
    p = colDef("p-value", width = 100, cell = function(val) {
      if (val < 0.001) "< 0.001" else sprintf("%.4f", val)
    })
  ),
  theme = reactableTheme(headerStyle = list(fontSize = "11px")))
# &e
Table 21

4.6 V-test par cluster (top 3 variables)

Code
# &s &VTEST - Top variables discriminantes par cluster

vtest_paris <- purrr::map_dfr(names(carac_paris$quanti), function(cl) {
  d <- carac_paris$quanti[[cl]]
  if (is.null(d)) return(NULL)
  d |>
    as.data.frame() |>
    tibble::rownames_to_column("variable") |>
    mutate(cluster = cl) |>
    head(3)
})

if (nrow(vtest_paris) > 0) {
  # Normaliser les noms de colonnes (espaces -> points)
  names(vtest_paris) <- gsub(" ", ".", names(vtest_paris))
  reactable(vtest_paris |> select(cluster, variable, v.test, Mean.in.category, Overall.mean),
    pagination = FALSE, compact = TRUE, groupBy = "cluster",
    defaultColDef = colDef(format = colFormat(digits = 2)),
    columns = list(
      cluster = colDef("Cluster", width = 80),
      variable = colDef("Variable", width = 120, format = NULL),
      v.test = colDef("v-test", width = 80, style = function(val) {
        if (!is.na(val) && abs(val) > 3) list(fontWeight = "bold") else list()
      }),
      Mean.in.category = colDef("Moy. cluster", width = 100),
      Overall.mean = colDef("Moy. globale", width = 100)
    ))
}
# &e
Table 22

4.7 Nommage des clusters

Paris+PC : C1 : px_rev + ouvriers | C2 : tx_pauv + emmenrec | C3 : px_evol + proprio | C4 : cadres + proprio

Marseille : C1 : emmenrec + ouvriers | C2 : proprio + px_evol | C3 : cadres + px_evol | C4 : px_rev + px_evol

La CAH identifie 4 profils-types par périmètre (k=4, sil≈0.22). À Paris+PC, le C4 “gentrification active” (220 IRIS, z-cadres=+1.12, z-emménagés=+0.74) correspond au processus classique de Clerval : montée des cadres + renouvellement résidentiel + accession propriété (z=+0.69). Le C3 “pression immobilière” (195 IRIS) montre les prix DVF en forte hausse (z=+1.01) sans renouvellement — gentrification par les prix uniquement. Variable la plus discriminante : cadres (Eta2=0.44). À Marseille, le C4 “choc immobilier” (45 IRIS) est la signature locale : ratio prix/revenu extrême (z=+1.26) mais emménagés récents en chute (z=-0.72) — pression immobilière sans remplacement de population. Le C3 (67 IRIS, z-cadres=+1.20) est le seul cluster marseillais à combiner montée des cadres et hausse des prix, soit une gentrification au sens plein.

4.8 Typologie communale

Les z-scores au niveau communal sont déjà calculés dans le pipeline (com_*.parquet). On les utilise pour classer chaque commune dans un type de trajectoire croisant profil socioéconomique de base et dynamique de gentrification.

4.8.1 Paris+PC — Typologie des 48 communes

Code
# &s &TYPO_TBL_PARIS - Tableau typologie Paris+PC

tbl_typo_paris <- com_paris_typed |>
  select(libelle, P22_POP, dsp_csp_cadres_pct_22, idxg_t2_ind,
         z_cad, z_ouv, z_rev, z_px, z_pxr, z_emm, z_pro,
         idxg_t2_com_sd, type_gentri) |>
  arrange(desc(idxg_t2_ind))

reactable(tbl_typo_paris,
  pagination = FALSE, compact = TRUE, height = 500,
  defaultColDef = colDef(format = colFormat(digits = 2), width = 65, align = "center"),
  columns = list(
    libelle = colDef("Commune", width = 160, align = "left",
      style = list(fontSize = "0.8em")),
    P22_POP = colDef("Pop.", width = 75, format = colFormat(separators = TRUE, digits = 0)),
    dsp_csp_cadres_pct_22 = colDef("Cadres %", width = 70,
      format = colFormat(digits = 1)),
    idxg_t2_ind = colDef("idx", width = 55,
      style = function(val) {
        if (!is.na(val) && val > 55) list(fontWeight = "bold", color = "#dc2626")
        else if (!is.na(val) && val < 45) list(color = "#999") else list()
      }),
    z_cad  = colDef("z_cad", style = zscore_style),
    z_ouv  = colDef("z_ouv", style = zscore_style),
    z_rev  = colDef("z_rev", style = zscore_style),
    z_px   = colDef("z_px", style = zscore_style),
    z_pxr  = colDef("z_pxr", style = zscore_style),
    z_emm  = colDef("z_emm", style = zscore_style),
    z_pro  = colDef("z_pro", style = zscore_style),
    idxg_t2_com_sd = colDef("SD", width = 55, format = colFormat(digits = 1)),
    type_gentri = colDef("Type", width = 140, align = "left",
      style = function(val) {
        col <- switch(val,
          "Gentri par les prix"  = "#dc2626",
          "Gentri par les CSP"   = "#ea580c",
          "Gentri equilibree"    = "#16a34a",
          "Deja riche / stable"  = "#6366f1",
          "En declin"            = "#9ca3af",
          "Transition moderee"   = "#0891b2",
          "#666")
        list(color = col, fontWeight = "600", fontSize = "0.8em")
      })
  ),
  theme = reactableTheme(
    headerStyle = list(fontSize = "9px", borderBottom = "2px solid #1696d2")))
# &e
Table 23

4.8.2 Marseille — Typologie des 17 arrondissements

Code
# &s &TYPO_TBL_MARS - Tableau typologie Marseille

tbl_typo_mars <- com_mars_typed |>
  select(libelle, P22_POP, dsp_csp_cadres_pct_22, idxg_t2_ind,
         z_cad, z_ouv, z_rev, z_px, z_pxr, z_emm, z_pro,
         idxg_t2_com_sd, type_gentri) |>
  arrange(desc(idxg_t2_ind))

reactable(tbl_typo_mars,
  pagination = FALSE, compact = TRUE,
  defaultColDef = colDef(format = colFormat(digits = 2), width = 65, align = "center"),
  columns = list(
    libelle = colDef("Commune", width = 160, align = "left",
      style = list(fontSize = "0.8em")),
    P22_POP = colDef("Pop.", width = 75, format = colFormat(separators = TRUE, digits = 0)),
    dsp_csp_cadres_pct_22 = colDef("Cadres %", width = 70,
      format = colFormat(digits = 1)),
    idxg_t2_ind = colDef("idx", width = 55,
      style = function(val) {
        if (!is.na(val) && val > 55) list(fontWeight = "bold", color = "#dc2626")
        else if (!is.na(val) && val < 45) list(color = "#999") else list()
      }),
    z_cad  = colDef("z_cad", style = zscore_style),
    z_ouv  = colDef("z_ouv", style = zscore_style),
    z_rev  = colDef("z_rev", style = zscore_style),
    z_px   = colDef("z_px", style = zscore_style),
    z_pxr  = colDef("z_pxr", style = zscore_style),
    z_emm  = colDef("z_emm", style = zscore_style),
    z_pro  = colDef("z_pro", style = zscore_style),
    idxg_t2_com_sd = colDef("SD", width = 55, format = colFormat(digits = 1)),
    type_gentri = colDef("Type", width = 140, align = "left",
      style = function(val) {
        col <- switch(val,
          "Gentri par les prix"  = "#dc2626",
          "Gentri par les CSP"   = "#ea580c",
          "Gentri equilibree"    = "#16a34a",
          "Deja riche / stable"  = "#6366f1",
          "En declin"            = "#9ca3af",
          "Transition moderee"   = "#0891b2",
          "#666")
        list(color = col, fontWeight = "600", fontSize = "0.8em")
      })
  ),
  theme = reactableTheme(
    headerStyle = list(fontSize = "9px", borderBottom = "2px solid #1696d2")))
# &e
Table 24

4.8.3 Répartition par type

Code
# &s &TYPO_SUMMARY - Résumé par type de gentrification

typo_all <- bind_rows(
  com_paris_typed |> mutate(perimetre = "Paris+PC"),
  com_mars_typed  |> mutate(perimetre = "Marseille")
)

typo_summary <- typo_all |>
  group_by(perimetre, type_gentri) |>
  summarise(
    n = n(),
    pop = sum(P22_POP, na.rm = TRUE),
    idx_med = round(median(idxg_t2_ind, na.rm = TRUE), 1),
    .groups = "drop"
  ) |>
  arrange(perimetre, desc(n))

reactable(typo_summary,
  pagination = FALSE, compact = TRUE,
  groupBy = "perimetre",
  defaultExpanded = TRUE,
  columns = list(
    perimetre   = colDef("Périmètre", width = 120),
    type_gentri = colDef("Type", width = 170,
      style = function(val) {
        col <- switch(val,
          "Gentri par les prix"  = "#dc2626",
          "Gentri par les CSP"   = "#ea580c",
          "Gentri equilibree"    = "#16a34a",
          "Deja riche / stable"  = "#6366f1",
          "En declin"            = "#9ca3af",
          "Transition moderee"   = "#0891b2",
          "#666")
        list(color = col, fontWeight = "600")
      }),
    n           = colDef("N communes", width = 90),
    pop         = colDef("Population", width = 120, format = colFormat(separators = TRUE, digits = 0)),
    idx_med     = colDef("idx médian", width = 90)
  ),
  theme = reactableTheme(
    headerStyle = list(fontSize = "10px", borderBottom = "2px solid #1696d2")))
# &e
Table 25

La typologie communale distingue trois formes de gentrification. À Paris+PC, 6 communes connaissent une gentrification tirée par les prix (Saint-Ouen, Clichy, Pantin — z_px > 0.5, z_cadres faible) : la pression immobilière précède la transformation socioprofessionnelle. 4 communes montrent une gentrification CSP (Gentilly, Le Kremlin-Bicêtre — z_cadres > 0.3, prix stables) : la montée des cadres se fait sans envolée des prix, possiblement par le parc social qui fixe les prix. 4 communes combinent les deux (Romainville, Le Pré-Saint-Gervais — gentrification équilibrée). Les 19 communes “déjà riches” (Neuilly, 16e, 7e) présentent des z-scores proches de zéro : leur statut social élevé est un état stable, pas un processus en cours. À Marseille, 10 des 17 arrondissements restent en “transition modérée” — les z-scores communaux atténuent les contrastes infra-communaux captés par l’ACP IRIS.

Classification basée sur les z-scores pré-calculés du pipeline (com_*.parquet). Les seuils sont ajustés au niveau communal (z-scores atténués par moyennage des IRIS). Cinq types : “déjà riche/stable” (cadres > 28 %, z faibles), “en déclin” (idx < 47), “gentri par les prix” (z_px > 0.5, z_cad < 0.15), “gentri CSP” (z_cad > 0.3, z_px faible), “gentri équilibrée” (≥ 3 z positifs, idx > 50).

Annexes

Le croisement SIRENE et la table de synthèse complètent l’analyse principale. Le premier teste l’hypothèse d’un lien entre gentrification résidentielle et renouvellement commercial ; la seconde rassemble les chiffres clés pour alimenter la note flash.

4.9 Croisement SIRENE

4.9.1 Dynamique de renouvellement commercial

Code
# Distribution du taux de renouvellement hors micro-E
df_renouv <- df_paris |>
  filter(!is.na(ecosi_renouv_horsmE_pct_26))

med_renouv <- median(df_renouv$ecosi_renouv_horsmE_pct_26, na.rm = TRUE)

ggplot(df_renouv, aes(x = ecosi_renouv_horsmE_pct_26, fill = dep)) +
  geom_histogram(bins = 40, alpha = 0.6, position = "identity") +
  geom_vline(xintercept = med_renouv, linetype = "dashed", color = "#444444") +
  annotate("text", x = med_renouv + 2, y = Inf, vjust = 2,
           label = sprintf("méd. = %.1f%%", med_renouv), size = 3) +
  scale_fill_manual(values = c("75" = col_cyan, "92" = col_green,
                                "93" = col_yellow, "94" = col_magenta)) +
  theme_urbn() +
  labs(
    title    = "Taux de renouvellement commercial hors micro-entreprises",
    subtitle = sprintf("Paris+PC · %s IRIS renseignés · SIRENE février 2026",
                       format(nrow(df_renouv), big.mark = " ")),
    x = "% établissements < 5 ans (hors micro-E)", y = "Fréquence", fill = "Dept",
    caption  = make_source("SIRENE géolocalisé", "février 2026")
  )

4.9.2 Corrélation gentrification / renouvellement commercial

Code
# VISUEL CLÉ — Scatter idx_gentri × renouvellement hors micro-E
df_scatter_sirene <- df_paris |>
  filter(!is.na(idxg_t2_ind), !is.na(ecosi_renouv_horsmE_pct_26))

lm_sirene <- lm(ecosi_renouv_horsmE_pct_26 ~ idxg_t2_ind, data = df_scatter_sirene)
r2_sirene <- summary(lm_sirene)$r.squared
rho_sirene <- cor(df_scatter_sirene$idxg_t2_ind, df_scatter_sirene$ecosi_renouv_horsmE_pct_26,
                  method = "spearman")

ggplot(df_scatter_sirene, aes(x = idxg_t2_ind, y = ecosi_renouv_horsmE_pct_26,
                               color = dep, size = P22_POP)) +
  geom_point(alpha = 0.4) +
  geom_smooth(method = "lm", se = TRUE, color = "gray30", linewidth = 0.8,
              inherit.aes = FALSE,
              aes(x = idxg_t2_ind, y = ecosi_renouv_horsmE_pct_26)) +
  scale_color_manual(values = c("75" = col_cyan, "92" = col_green,
                                 "93" = col_yellow, "94" = col_magenta)) +
  scale_size_continuous(range = c(0.5, 4), guide = "none") +
  annotate("text", x = 80, y = max(df_scatter_sirene$ecosi_renouv_horsmE_pct_26, na.rm = TRUE) * 0.95,
           label = sprintf("R² = %.3f · ρ = %.3f\n%s IRIS",
                           r2_sirene, rho_sirene,
                           format(nrow(df_scatter_sirene), big.mark = " ")),
           size = 3.5, color = "gray30", hjust = 1) +
  theme_urbn() +
  labs(
    title    = "Gentrification vs renouvellement commercial — Paris+PC",
    subtitle = sprintf("R² = %.3f · ρ Spearman = %.3f · %s IRIS",
                       r2_sirene, rho_sirene,
                       format(nrow(df_scatter_sirene), big.mark = " ")),
    x = "idx_gentri T2 (0-100)",
    y = "Taux renouvellement hors micro-E (%)",
    color = "Dept",
    caption = make_source("RP INSEE", "Filosofi", "DVF", "SIRENE février 2026")
  )

4.9.3 IRIS atypiques (résidus extrêmes)

Code
# IRIS avec résidus > 2σ dans la régression gentri ~ renouvellement
df_scatter_sirene$residu <- residuals(lm_sirene)
sigma_res <- sd(df_scatter_sirene$residu, na.rm = TRUE)

atypiques <- df_scatter_sirene |>
  filter(abs(residu) > 2 * sigma_res) |>
  mutate(
    type_atypique = case_when(
      residu > 0  ~ "Fort renouvellement (résidu +)",
      residu < 0  ~ "Faible renouvellement (résidu −)"
    )
  ) |>
  arrange(desc(abs(residu))) |>
  head(20) |>
  select(code, libelle, libelle_com, dep, idxg_t2_ind,
         ecosi_renouv_horsmE_pct_26, residu, type_atypique)

n_atypiques <- nrow(df_scatter_sirene |> filter(abs(residu) > 2 * sigma_res))

reactable(atypiques,
  pagination = FALSE, compact = TRUE,
  columns = list(
    code                       = colDef("Code IRIS", width = 110),
    libelle                    = colDef("IRIS", width = 160),
    libelle_com                = colDef("Commune", width = 120),
    dep                        = colDef("Dept", width = 50),
    idxg_t2_ind                = colDef("idx_gentri", width = 80, format = colFormat(digits = 1)),
    ecosi_renouv_horsmE_pct_26 = colDef("Renouv. (%)", width = 90, format = colFormat(digits = 1)),
    residu                     = colDef("Résidu", width = 80, format = colFormat(digits = 1)),
    type_atypique              = colDef("Type", width = 200)
  ))
Table 26

R²≈0 entre gentrification et renouvellement SIRENE — résultat majeur. La gentrification résidentielle (hausse cadres, prix, revenus) ne se traduit pas mécaniquement par un renouvellement du tissu commercial. Le taux de renouvellement médian est quasi constant quel que soit le quartile de gentrification (Q1=38.8%, Q4=37.5%). Les 65 IRIS atypiques (résidus >2σ) racontent des histoires locales : centralité, flux de transit, politique commerciale.

Forte gentrification + faible renouvellement = résidentialisation possible (tissu commercial figé). Faible gentrification + fort renouvellement = dynamique commerciale autonome (quartiers populaires avec création d’entreprises).

4.9.4 Diversité sectorielle et gentrification

Code
# Shannon A38 par quartile de gentrification
df_shannon <- df_paris |>
  filter(!is.na(idxg_t2_ind), !is.na(ecosi_shannon_ind_26)) |>
  mutate(quartile_gentri = cut(idxg_t2_ind,
    breaks = quantile(idxg_t2_ind, probs = c(0, 0.25, 0.5, 0.75, 1), na.rm = TRUE),
    labels = c("Q1 (faible)", "Q2", "Q3", "Q4 (forte)"),
    include.lowest = TRUE
  ))

ggplot(df_shannon, aes(x = quartile_gentri, y = ecosi_shannon_ind_26, fill = quartile_gentri)) +
  geom_boxplot(alpha = 0.7, outlier.size = 0.5) +
  scale_fill_manual(values = c("#f0ebe0", "#fdbf11", "#ec008b", "#5b1a8c")) +
  theme_urbn() +
  theme(legend.position = "none") +
  labs(
    title    = "Diversité sectorielle (Shannon A38) par quartile de gentrification",
    subtitle = sprintf("Paris+PC · %s IRIS · Q4 = IRIS les plus gentrifiés",
                       format(nrow(df_shannon), big.mark = " ")),
    x = "Quartile idx_gentri T2", y = "Indice de Shannon (A38)",
    caption  = make_source("SIRENE février 2026")
  )

Le croisement avec le renouvellement commercial SIRENE constitue un résultat central du notebook : le R² est nul (0.000), le ρ de Spearman est de −0.047, la pente de −0.012 est non significative. La gentrification résidentielle mesurée par l’indice ne se traduit pas mécaniquement par un renouvellement du tissu commercial. Les médianes de renouvellement par quartile d’indice sont quasi plates (Q1 : 38.8 %, Q2 : 38.3 %, Q3 : 36.9 %, Q4 : 37.5 %). Ce découplage peut s’expliquer par la temporalité : le renouvellement commercial est plus lent que la transformation résidentielle, ou par la structure commerciale francilienne (baux commerciaux longs, centralités commerciales préexistantes). Parmi les 65 IRIS atypiques (résidus > 2σ), les plus remarquables sont à Clichy (idx=55.2, renouv=91.3 %) et Paris 8e (idx=45.9, renouv=90.4 %) — des cas de renouvellement exceptionnel indépendant du niveau de gentrification.

4.10 Synthèse chiffres clés

# Collecter tous les résultats clés pour injection dans la note flash
loo_min_paris <- min(loo_paris$rho_spearman)
loo_max_paris <- max(loo_paris$rho_spearman)

synthese <- tibble(
  Indicateur = c(
    "N IRIS Paris+PC",
    "N IRIS Marseille",
    "Mediane idx T2 Paris+PC",
    "Mediane idx T2 Marseille",
    "rho min leave-one-out (Paris+PC)",
    "rho max leave-one-out (Paris+PC)",
    "rho capped vs uncapped (Paris+PC)",
    "rho ponderation uniforme vs double PCS",
    "R2 gentri ~ renouvellement SIRENE",
    "rho Spearman gentri ~ renouvellement",
    "N IRIS atypiques (residu > 2s)",
    "N IRIS gentrification complete (>=5/7)",
    "rho Spearman T2 vs Long",
    "KMO global Paris+PC",
    "KMO global Marseille",
    "Silhouette best Paris+PC",
    "Silhouette best Marseille",
    "CAH Paris+PC : silhouette moyenne",
    "CAH Marseille : silhouette moyenne",
    "CAH Paris+PC : k clusters",
    "CAH Marseille : k clusters",
    "Typo : N types identifies Paris+PC",
    "Typo : N communes gentrification Paris+PC"
  ),
  Valeur = c(
    format(n_paris, big.mark = " "),
    format(n_mars, big.mark = " "),
    sprintf("%.1f", med_paris),
    sprintf("%.1f", med_mars),
    sprintf("%.4f", loo_min_paris),
    sprintf("%.4f", loo_max_paris),
    sprintf("%.4f", rho_cap_paris),
    sprintf("%.4f", rho_ponder),
    sprintf("%.4f", r2_sirene),
    sprintf("%.4f", rho_sirene),
    as.character(n_atypiques),
    sprintf("%d (%.1f%%)", n_complet_5, pct_complet_5),
    sprintf("%.4f", rho_t2long),
    sprintf("%.3f", kmo_paris$MSA),
    sprintf("%.3f", kmo_mars$MSA),
    sprintf("%.3f (axes=%d, k=%d)", best_paris$silhouette, best_paris$n_axes, best_paris$k),
    sprintf("%.3f (axes=%d, k=%d)", best_mars$silhouette, best_mars$n_axes, best_mars$k),
    sprintf("%.3f", sil_mean_paris),
    sprintf("%.3f", sil_mean_mars),
    as.character(K_PARIS),
    as.character(K_MARS),
    as.character(n_distinct(com_paris_typed$type_gentri)),
    as.character(sum(grepl("Gentri", com_paris_typed$type_gentri)))
  ),
  Destination = c(
    "KPI card", "KPI card",
    "KPI card", "KPI card",
    "Encadre robustesse", "Encadre robustesse",
    "Encadre robustesse", "Encadre robustesse",
    "Fig. 2 annotation", "Fig. 2 annotation",
    "Paragraphe resultats", "Paragraphe resultats",
    "Section T2 vs Long",
    "Section ACP", "Section ACP",
    "Section CAH", "Section CAH",
    "Section CAH", "Section CAH",
    "Section CAH", "Section CAH",
    "Section Typologie", "Section Typologie"
  ),
  Seuil = c(
    "", "", "", "",
    ">= 0.90 OCDE", ">= 0.90 OCDE",
    ">= 0.99", ">= 0.95",
    "", "",
    "", "< 20% attendu",
    ">= 0.80",
    "> 0.50 conv.", "> 0.50 conv.",
    "> 0.25 Kaufman", "> 0.25 Kaufman",
    "> 0.25 Kaufman", "> 0.25 Kaufman",
    "", "",
    "", ""
  )
)

reactable(synthese,
  pagination = FALSE, compact = TRUE,
  columns = list(
    Indicateur  = colDef(width = 280),
    Valeur      = colDef(width = 120, style = list(fontWeight = "bold")),
    Destination = colDef(width = 170),
    Seuil       = colDef(width = 120,
                         style = list(color = "#999", fontStyle = "italic"))
  ),
  theme = reactableTheme(
    headerStyle = list(borderBottom = "2px solid #1696d2", fontSize = "0.85em"),
    cellStyle = list(fontSize = "0.85em")
  ))
Table 27

4.11 Pour approfondir