Passer au contenu

Quand une température devient une classe

Îlots de chaleur à Québec : comprendre le classement par k-means

Auteur·rice

Aurélien Nicosia · Données bleues

Date de publication

5 septembre 2026

1 La question

Une carte colorée en classes semble tracer des frontières évidentes entre des secteurs frais et chauds. Pourtant, les données de départ sont continues. Comment l’algorithme place-t-il ces frontières ? Les mêmes seuils apparaissent-ils si l’on change le nombre de classes ou le territoire étudié ?

Nous allons extraire l’idée statistique du classement et la refaire avec des valeurs issues de la cartographie québécoise des écarts de température de surface de 2020 à 2022.

Parcours proposé : 90 minutes. Préalables : moyenne, variance et bases de R. À la fin, vous pourrez expliquer l’objectif de k-means, ordonner ses classes, comparer plusieurs valeurs de kk et reconnaître la dépendance des seuils au territoire analysé.

NoteL’étape de l’analyse que nous refaisons

Le rapport CERFO de Budei et ses collègues (2023) décrit une chaîne comprenant la prédiction des écarts de température et leur classement par k-means.

Nous refaisons ici le classement sur un extrait autour de la ville de Québec. Les valeurs sont des prédictions du produit cartographique diffusé par l’INSPQ et réalisé par le CERFO. Nous ne réentraînons pas sa forêt aléatoire : les données originales d’apprentissage ne sont pas incluses. Nos classes locales ne sont pas les classes officielles, calculées sur des centres de population complets.

2 Que mesure-t-on ?

La fiche du jeu de données INSPQ définit l’écart comme la différence de température de surface en ville par rapport à un boisé à proximité. La grille diffusée a un pas de 15 m. Ce ne sont pas des températures de l’air ni des températures ressenties par des personnes.

Pour rendre le calcul léger, le fichier fourni retient un pixel sur quatre dans chaque direction, dans une fenêtre demandée de longitude −71,28 à −71,20 et de latitude 46,78 à 46,84. Les valeurs manquantes sont exclues. Les centres retenus sont donc espacés de 60 m. Il s’agit d’un sous-échantillonnage systématique de pixels de 15 m, sans moyenne par bloc et sans création de mesures nouvelles.

Les coordonnées sont exprimées en mètres dans le système NAD83 / Québec Lambert, EPSG:32198. Le script d’acquisition fourni permet de retrouver la fenêtre dans le raster source.

library(readr)
library(dplyr)
library(tidyr)
library(ggplot2)
library(knitr)
library(scales)

theme_set(theme_minimal(base_size = 13))
fmt <- function(x, digits = 1) {
  formatC(x, format = "f", digits = digits, decimal.mark = ",")
}
chaleur <- read_csv("data/chaleur-quebec-2020-2022.csv",
                    col_types = cols(.default = col_double()))
stopifnot(nrow(chaleur) > 1000, !anyDuplicated(chaleur[c("x_m", "y_m")]),
          all(is.finite(chaleur$ecart_c)), all(chaleur$ecart_c != -99999))

chaleur |>
  summarise(Pixels = n(), Minimum = min(ecart_c),
            Mediane = median(ecart_c), Maximum = max(ecart_c)) |>
  kable(digits = 2, caption = "Écarts de température de surface dans l’extrait (°C).")
Écarts de température de surface dans l’extrait (°C).
Pixels Minimum Mediane Maximum
9806 2.29 11.7 19.93

Cet extrait contient 9806 valeurs. Le résultat décrit cette fenêtre et cet échantillonnage. Les pixels proches sont spatialement dépendants : leur nombre ne correspond pas à autant d’observations indépendantes pour une inférence statistique.

ggplot(chaleur, aes(x_m / 1000, y_m / 1000, fill = ecart_c)) +
  geom_tile(width = .06, height = .06) +
  scale_fill_viridis_c(option = "C", name = "Écart (°C)") +
  coord_equal() +
  labs(x = "Coordonnée est (km, Québec Lambert)",
       y = "Coordonnée nord (km)") +
  theme(panel.background = element_rect(fill = "#e9edef", color = NA),
        panel.grid = element_blank())

Carte continue des écarts de température de surface prédits dans une fenêtre autour de Québec, du plus faible au plus élevé.

Écarts prédits dans l’extrait autour de Québec. Chaque carré affiche un pixel retenu, agrandi au pas de 60 m pour la lecture ; aucune moyenne à 60 m n’a été calculée. Les espaces sans valeur sont gris.

Avant de classer, observez la continuité des valeurs. Les différences spatiales de couleur n’identifient pas à elles seules un mécanisme causal : nous n’avons pas introduit les variables d’occupation du sol dans cette démonstration.

3 Comprendre l’objectif de k-means

Pour kk classes, on cherche à minimiser la somme des carrés des écarts aux centres de classe :

Wk=g=1ki:ci=g(xiμg)2.W_k=\sum_{g=1}^{k}\sum_{i:c_i=g}(x_i-\mu_g)^2.

Chaque xix_i est ici un écart de température, cic_i son groupe et μg\mu_g la moyenne de ce groupe. L’algorithme alterne affectation au centre le plus proche et mise à jour des centres. Les coordonnées géographiques ne participent pas au calcul : deux pixels éloignés ayant la même valeur seront traités de la même manière.

Nous commençons par trois classes. Plusieurs initialisations réduisent la dépendance à un mauvais point de départ, sans constituer une preuve d’optimum global.

# Les numéros bruts de kmeans sont arbitraires. Cette fonction les ordonne
# du plus petit au plus grand centre, ce qui rend les cartes interprétables.
classer <- function(valeurs, k, graine = 4230) {
  set.seed(graine)
  modele <- kmeans(matrix(valeurs, ncol = 1), centers = k,
                   nstart = 25, iter.max = 200)
  ordre <- order(modele$centers[, 1])
  list(modele = modele,
       centres = as.numeric(modele$centers[ordre, 1]),
       classe = match(modele$cluster, ordre))
}

k3 <- classer(chaleur$ecart_c, k = 3)
chaleur <- chaleur |> mutate(classe3 = k3$classe)
resume3 <- chaleur |>
  group_by(classe3) |>
  summarise(n = n(), minimum = min(ecart_c), centre = mean(ecart_c),
            maximum = max(ecart_c), .groups = "drop")
resume3 |> kable(digits = 2,
  caption = "Trois classes calculées sur l’extrait. Les centres sont les moyennes de classe.")
Trois classes calculées sur l’extrait. Les centres sont les moyennes de classe.
classe3 n minimum centre maximum
1 3134 2.29 6.35 8.76
2 2913 8.76 11.17 13.05
3 3759 13.05 14.93 19.93
stopifnot(all(diff(k3$centres) > 0), sum(resume3$n) == nrow(chaleur))

En une dimension, la frontière entre deux centres consécutifs est leur milieu. Elle dépend des valeurs observées et de kk. Les classes ne contiennent pas nécessairement le même nombre de pixels.

seuils3 <- (head(k3$centres, -1) + tail(k3$centres, -1)) / 2
ggplot(chaleur, aes(ecart_c)) +
  geom_histogram(bins = 45, fill = "#267b96", color = "white") +
  geom_vline(xintercept = seuils3, linetype = "dashed", color = "#b45229") +
  labs(x = "Écart de température de surface (°C)", y = "Nombre de pixels retenus")

Histogramme des écarts de température, séparé par les deux seuils calculés entre les trois centres k-means.

La distribution continue et les deux frontières du classement en trois groupes.
chaleur |>
  mutate(groupe = factor(classe3, levels = 1:3,
                         labels = c("1 · Faible", "2 · Intermédiaire", "3 · Élevé"))) |>
  ggplot(aes(x_m / 1000, y_m / 1000, fill = groupe)) +
  geom_tile(width = .06, height = .06) +
  scale_fill_manual(values = c("#366c98", "#e1c886", "#ae422f"), name = "Classe locale") +
  coord_equal() + labs(x = "Coordonnée est (km, Québec Lambert)",
                       y = "Coordonnée nord (km)") +
  theme(panel.background = element_rect(fill = "#e9edef", color = NA),
        panel.grid = element_blank())

Carte des trois groupes k-means dans le secteur étudié, classés du plus faible au plus grand écart de température.

Trois groupes ordonnés selon leurs centres. Les mots faible, intermédiaire et élevé se rapportent uniquement aux valeurs de cet extrait.

Nous avons créé une partition numérique, pas observé trois catégories naturelles. Une faible différence de part et d’autre d’un seuil peut produire un changement de couleur net sur la carte.

4 Pourquoi pas neuf classes ?

Le produit officiel utilise neuf classes. Pour comprendre ce choix, calculons d’abord plusieurs partitions. La dispersion interne diminue lorsque l’on autorise davantage de groupes ; sa diminution ne suffit donc pas à choisir kk.

dispersion_totale <- sum((chaleur$ecart_c - mean(chaleur$ecart_c))^2)
coude <- tibble(k = 1:10,
  W = vapply(1:10, function(k) {
    classer(chaleur$ecart_c, k)$modele$tot.withinss
  }, numeric(1))) |>
  mutate(fraction = W / dispersion_totale)
ggplot(coude, aes(k, fraction)) +
  geom_line(color = "#267b96") + geom_point(color = "#267b96", size = 2.7) +
  scale_x_continuous(breaks = 1:10) +
  scale_y_continuous(labels = label_percent()) +
  labs(x = "Nombre de classes k", y = "Dispersion interne / dispersion totale")

Courbe de la dispersion interne pour un à dix groupes k-means.

Dispersion interne relative au classement à un seul groupe. Le graphique aide à apprécier le gain obtenu en ajoutant une classe.

La courbe du coude est un outil de discussion, pas une preuve qu’un nombre de classes est vrai. Il faut aussi considérer la lisibilité, l’objectif de la carte et la perte d’information induite par les seuils.

k9 <- classer(chaleur$ecart_c, k = 9)
chaleur <- chaleur |> mutate(classe9 = k9$classe)
ggplot(chaleur, aes(x_m / 1000, y_m / 1000, fill = factor(classe9))) +
  geom_tile(width = .06, height = .06) +
  scale_fill_viridis_d(option = "C", name = "Classe locale") +
  coord_equal() + labs(x = "Coordonnée est (km, Québec Lambert)",
                       y = "Coordonnée nord (km)") +
  theme(panel.background = element_rect(fill = "#e9edef", color = NA),
        panel.grid = element_blank())

Carte de neuf classes k-means ordonnées par écart de température croissant sur l’extrait.

Neuf classes recalculées localement. Elles ne correspondent pas aux numéros des classes officielles de l’INSPQ.

Même avec neuf groupes, nous ne retrouvons pas nécessairement les seuils officiels : nous utilisons un secteur plus petit et un pixel sur seize. La fiche INSPQ précise que les intervalles officiels sont calculés par centre de population. Un numéro de classe n’a donc pas partout la même signification en degrés Celsius.

5 Le territoire déplace-t-il les seuils ?

Refaisons le classement à trois groupes sur la moitié ouest de la fenêtre. Pour comparer équitablement les affectations, nous évaluons les deux partitions sur ces mêmes pixels de l’ouest.

ouest <- chaleur |> filter(x_m <= mean(range(chaleur$x_m)))
k3_ouest <- classer(ouest$ecart_c, k = 3)
seuils_ouest <- (head(k3_ouest$centres, -1) + tail(k3_ouest$centres, -1)) / 2

tibble(
  Frontiere = c("Classe 1 vers classe 2", "Classe 2 vers classe 3"),
  fenetre_complete = seuils3,
  moitie_ouest = seuils_ouest
) |> kable(digits = 2, caption = "Seuils de classement, en °C.")
Seuils de classement, en °C.
Frontiere fenetre_complete moitie_ouest
Classe 1 vers classe 2 8.76 8.80
Classe 2 vers classe 3 13.05 13.13
part_changee <- mean(ouest$classe3 != k3_ouest$classe)

Les seuils ont été recalculés avec un autre ensemble de valeurs. Dans la moitié ouest, 1,1 % des pixels changent de numéro de classe entre les deux calculs. Leur écart de température n’a pourtant pas changé. Le groupe décrit une position dans une distribution de référence.

Vérifions également la sensibilité aux initialisations, sur la fenêtre complète :

robustesse <- tibble(
  graine = c(1, 10, 100, 4230),
  W3 = vapply(graine, function(s) {
    classer(chaleur$ecart_c, 3, graine = s)$modele$tot.withinss
  }, numeric(1)))
robustesse |> kable(digits = 3)
graine W3
1 15892.29
10 15892.29
100 15892.29
4230 15892.29

Des dispersions identiques ou très proches indiquent que ces essais convergent vers des solutions comparables pour ce jeu de données. Cela ne valide ni les prédictions de température en entrée, ni l’intérêt des classes pour une décision d’aménagement.

6 À vous de jouer

  1. Deux pixels séparés par plusieurs kilomètres possèdent le même écart de température. Peuvent-ils être affectés à des groupes différents par ce modèle ?
  2. Remplacez k-means par trois groupes de quantiles. Qu’impose cette autre méthode ?
  3. Ajoutez 2 °C à toutes les valeurs et refaites k-means avec la même graine. Les frontières numériques changent-elles ? Et les groupes ?
  4. La classe locale 9 permet-elle d’affirmer que la température de l’air y est dangereuse pour la santé ?

Les coordonnées n’entrent pas dans ce modèle. Deux valeurs identiques ont les mêmes distances aux centres et reçoivent la même affectation, à une éventuelle convention d’égalité près.

Les terciles cherchent des groupes de tailles proches, alors que k-means minimise des distances au carré. Des égalités de valeurs aux seuils peuvent empêcher des effectifs exactement identiques.

terciles <- quantile(chaleur$ecart_c, probs = c(0, 1/3, 2/3, 1))
groupes_terciles <- cut(chaleur$ecart_c, breaks = terciles,
                        include.lowest = TRUE, labels = FALSE)
table(groupes_terciles)
groupes_terciles
   1    2    3 
3269 3268 3269 
translation <- classer(chaleur$ecart_c + 2, 3)
stopifnot(all(translation$classe == k3$classe),
          max(abs(translation$centres - k3$centres - 2)) < 1e-6)

Ajouter la même constante translate les centres et les seuils sans modifier les distances entre observations. Les classes restent les mêmes dans ce calcul.

Enfin, une classe d’écart de température de surface n’est ni une mesure de température de l’air ni un seuil clinique. Il faudrait d’autres observations, une validation et une définition adaptée à l’usage visé. La seule carte ne permet pas cette conclusion.

7 Sources et reproductibilité

Le fichier data/chaleur-quebec-2020-2022.csv est inclus. Les calculs n’exigent ni téléchargement du raster complet ni bibliothèque géospatiale. Le script sources/reconstituer-extraits.R documente séparément l’acquisition avec sf et GDAL.

sessionInfo()
R version 4.5.0 (2025-04-11)
Platform: x86_64-pc-linux-gnu
Running under: Ubuntu 24.04.4 LTS

Matrix products: default
BLAS:   /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3 
LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.26.so;  LAPACK version 3.12.0

locale:
 [1] LC_CTYPE=C.UTF-8       LC_NUMERIC=C           LC_TIME=C.UTF-8       
 [4] LC_COLLATE=C.UTF-8     LC_MONETARY=C.UTF-8    LC_MESSAGES=C.UTF-8   
 [7] LC_PAPER=C.UTF-8       LC_NAME=C              LC_ADDRESS=C          
[10] LC_TELEPHONE=C         LC_MEASUREMENT=C.UTF-8 LC_IDENTIFICATION=C   

time zone: UTC
tzcode source: system (glibc)

attached base packages:
[1] stats     graphics  grDevices utils     datasets  methods   base     

other attached packages:
[1] scales_1.4.0  knitr_1.51    ggplot2_4.0.2 tidyr_1.3.2   dplyr_1.2.1  
[6] readr_2.1.5  

loaded via a namespace (and not attached):
 [1] bit_4.6.0          gtable_0.3.6       jsonlite_2.0.0     crayon_1.5.3      
 [5] compiler_4.5.0     tidyselect_1.2.1   parallel_4.5.0     yaml_2.3.12       
 [9] fastmap_1.2.0      R6_2.6.1           labeling_0.4.3     generics_0.1.4    
[13] tibble_3.3.1       pillar_1.11.1      RColorBrewer_1.1-3 tzdb_0.5.0        
[17] rlang_1.3.0        xfun_0.56          S7_0.2.0           bit64_4.6.0-1     
[21] viridisLite_0.4.2  cli_3.6.6          withr_3.0.3        magrittr_2.0.4    
[25] digest_0.6.39      grid_4.5.0         vroom_1.6.5        hms_1.1.3         
[29] lifecycle_1.0.5    vctrs_0.7.3        evaluate_1.0.5     glue_1.8.0        
[33] farver_2.1.2       rmarkdown_2.31     purrr_1.2.1        tools_4.5.0       
[37] pkgconfig_2.0.3    htmltools_0.5.9