Train/test split : séparer ses données pour le machine learning

Tutoriels R
Comment diviser correctement ses données entre ensemble d’entraînement et de test en R avec rsample et spatialsample
Auteur·rice

Thelma Panaïotis

Date de publication

23 janvier 2026

Mots clés

machine learning, train test split, rsample, spatialsample, tidymodels, R

Train/test split : séparer ses données pour le machine learning

Comprendre pourquoi et comment diviser vos données

Dans ce tutoriel, on va utiliser l’écosystème tidymodels pour gérer nos splits de données. Plus précisément, on utilisera rsample pour les splits classiques et spatialsample pour les données spatiales. Ces packages s’intègrent parfaitement avec les autres éléments de tidymodels que nous verrons dans de prochains tutoriels.

Les fondamentaux du split avec rsample

Pourquoi séparer ses données ?

Lorsqu’on construit un modèle de machine learning, on a deux objectifs contradictoires :

  1. apprendre des patterns dans nos données (entraînement) ;
  2. s’assurer que ces patterns se généralisent à de nouvelles données (évaluation).

Si on utilise les mêmes données pour ces deux étapes, on ne peut pas savoir si notre modèle a vraiment appris quelque chose d’utile ou s’il a simplement mémorisé nos données d’entraînement.

ImportantLe principe fondamental

Ne jamais évaluer un modèle sur les données qui ont servi à l’entraîner.

C’est comme corriger ses propres copies : on sera toujours trop optimiste sur nos performances réelles.

Notre jeu de données exemple

On va utiliser le dataset palmerpenguins qui contient des mesures morphologiques sur 3 espèces de manchots en Antarctique.

Les manchots de l’archipel Palmer. Artwork by @allison_horst.
library(palmerpenguins)
library(tidyverse)
library(rsample)

# Nettoyons les données
penguins_clean <- penguins |> 
  drop_na()

# Aperçu des données
glimpse(penguins_clean)
Rows: 333
Columns: 8
$ species           <fct> Adelie, Adelie, Adelie, Adelie, Adelie, Adelie, Adel…
$ island            <fct> Torgersen, Torgersen, Torgersen, Torgersen, Torgerse…
$ bill_length_mm    <dbl> 39.1, 39.5, 40.3, 36.7, 39.3, 38.9, 39.2, 41.1, 38.6…
$ bill_depth_mm     <dbl> 18.7, 17.4, 18.0, 19.3, 20.6, 17.8, 19.6, 17.6, 21.2…
$ flipper_length_mm <int> 181, 186, 195, 193, 190, 181, 195, 182, 191, 198, 18…
$ body_mass_g       <int> 3750, 3800, 3250, 3450, 3650, 3625, 4675, 3200, 3800…
$ sex               <fct> male, female, female, female, male, female, male, fe…
$ year              <int> 2007, 2007, 2007, 2007, 2007, 2007, 2007, 2007, 2007…
# Répartition des espèces
ggplot(penguins_clean) + 
  geom_bar(aes(x = species, fill = species), show.legend = FALSE) +
  geom_text(stat = "count", aes(x = species, label = after_stat(count)), vjust = -0.5, size = 4) +
  scale_fill_manual(values = penguin_colours) +
  labs(x = "Espèce", y = "Nombre d'observations", title = "Répartition des espèces")

Notre objectif sera de prédire l’espèce d’un manchot à partir de ses mesures morphologiques (longueur et profondeur du bec, longueur de la nageoire, masse corporelle).

Le split simple : 80/20 ou 70/30 ?

On entend souvent “80% train, 20% test” ou “70/30”, mais la bonne proportion dépend de votre jeu de données.

Le principe à retenir : l’ensemble de test doit contenir suffisamment d’observations pour être statistiquement représentatif. En pratique, on vise au minimum 30 observations par classe en classification, et idéalement 100+ observations au total pour avoir des métriques stables.

Quelques repères :

  • beaucoup de données (> 5000 observations) : 80/20 ou même 90/10 ;

    → exemple : avec 10000 obs, 10% = 1000 en test, largement suffisant

  • données moyennes (500 - 5000 observations) : 75/25 ou 70/30 ;

    → compromis entre apprendre suffisamment et évaluer correctement

  • peu de données (< 500 observations) : 70/30 minimum, ou privilégiez la validation croisée.

    → important d’avoir au moins 30 obs par classe dans le test

Avec nos 333 manchots, on a relativement peu de données. Un split 70/30 nous donnera ~100 manchots en test, ce qui est le minimum pour évaluer le modèle de manière fiable. En dessous, il faudrait envisager la validation croisée.

# Split 70/30
set.seed(123)  # Pour la reproductibilité
split <- initial_split(penguins_clean, prop = 0.7)

train_data <- training(split)
test_data <- testing(split)

Observations dans train : 233

Observations dans test : 100

AstuceToujours fixer le seed !

set.seed() garantit que vous obtiendrez le même split à chaque fois. Essentiel pour la reproductibilité.

Le split stratifié : préserver les proportions

Le problème du split aléatoire simple

Regardons les proportions d’espèces dans notre jeu complet, puis dans différents splits aléatoires avec 3 seeds différents (même code, résultats différents) :

On remarque que les proportions varient d’un split à l’autre ! Cela peut biaiser l’évaluation du modèle, surtout pour Chinstrap qui est déjà l’espèce la moins représentée.

La solution : le split stratifié

Stratifier signifie s’assurer que les proportions de certaines variables sont identiques dans le train et le test.

  • pour une classification : on stratifie sur la variable cible (ici, species) ;
  • pour une régression : on peut stratifier sur les quantiles de la variable continue \(Y\) pour s’assurer d’avoir toute la gamme de valeurs dans train et test.

Revenons à nos manchots :

# Split stratifié sur la variable species
split_stratified <- initial_split(
  penguins_clean, 
  prop = 0.7,
  strata = species  # Préserve les proportions d'espèces
)

train_strat <- training(split_stratified)
test_strat <- testing(split_stratified)

Les proportions sont maintenant quasi identiques entre train et test !

NoteQuand stratifier ?
  • classification : toujours stratifier sur la variable cible ;
  • régression : stratifier sur des quantiles si la distribution de \(Y\) est très déséquilibrée ;
  • données structurées : attention aux structures hiérarchiques (voir section suivante).

Les cas particuliers : données temporelles et spatiales

Séries temporelles : le split chronologique

Pour les données temporelles, le split aléatoire n’a aucun sens ! On veut prédire le futur, pas des moments aléatoires du passé.

Pourquoi c’est différent ?

Imaginons qu’on suive quotidiennement des manchots sur une année (données simulées). Leur masse varie avec les saisons : ils accumulent des réserves pendant l’été austral (novembre-mars) et les perdent en hiver.

La bonne approche : split chronologique

Pour effectuer un split chronologique, on va utiliser la fonction initial_time_split() du package rsample.

library(rsample)

# Split temporel avec rsample
ts_split <- initial_time_split(penguins_ts, prop = 0.7)

train_ts <- training(ts_split) |> mutate(set = "Train")
test_ts <- testing(ts_split) |> mutate(set = "Test")

# Combinons pour le plot
penguins_split <- bind_rows(train_ts, test_ts)

# Visualisation
ggplot(penguins_split, aes(date, body_mass_g, color = set)) +
  geom_line(linewidth = 1) +
  scale_color_manual(values = c("Train" = "#6BAED6", "Test" = "#F17D52")) +
  labs(
    title = "Split temporel avec rsample",
    x = "Date", 
    y = "Masse corporelle (g)",
    color = NULL
  ) +
  theme_minimal()

AvertissementJamais de données futures dans le train !

En séries temporelles, le test set doit toujours être postérieur au train set. Sinon, vous utilisez le futur pour prédire le passé (fuite de données).

Astucersample pour les séries temporelles

Le package rsample propose des fonctions spécialisées (sliding_window(), sliding_index(), sliding_period()) pour gérer automatiquement ces splits temporels. On les verra en détail dans le tutoriel sur la validation croisée.

Données spatiales : spatialsample et autocorrélation

Le problème de l’autocorrélation spatiale

En écologie, océanographie, ou géographie, les observations spatialement proches sont souvent similaires (autocorrélation spatiale).

Si vous mélangez aléatoirement des points proches entre train et test, votre modèle aura des performances artificiellement augmentées car il “voit” déjà des données très similaires.

La solution : regrouper les points proches

L’idée est de mettre les observations spatialement proches dans le même groupe (train ou test), plutôt que de les mélanger aléatoirement. Deux approches principales :

  • clustering spatial : algorithme qui regroupe les points proches ;
  • block spatial : découpage géographique en blocs réguliers.

Ici, on va utiliser le clustering spatial avec spatialsample.

Revenons à nos manchots

Ajoutons des coordonnées géographiques fictives à nos manchots, avec 3 zones géographiques distinctes.

Conversion en objet spatial

Pour utiliser spatialsample, il faut d’abord convertir nos données en objet sf (simple features).

# Conversion en objet sf
penguins_sf <- st_as_sf(
  penguins_spatial, 
  coords = c("longitude", "latitude"),
  crs = 4326  # Système de coordonnées WGS84
)

Split spatial avec clustering

# Split spatial : on crée 2 groupes (équivalent train/test)
spatial_split <- spatial_clustering_cv(
  penguins_sf,
  v = 2  # 2 groupes seulement pour simuler train/test
)

# Récupérons le premier split (groupe 1 = train, groupe 2 = test)
train_spatial <- analysis(spatial_split$splits[[1]]) |> mutate(set = "Train")
test_spatial <- assessment(spatial_split$splits[[1]]) |> mutate(set = "Test")

# Combinons pour visualisation
spatial_combined <- bind_rows(train_spatial, test_spatial)

# Extrayons les coordonnées pour ggplot
spatial_coords <- spatial_combined |>
  mutate(
    longitude = st_coordinates(spatial_combined)[,1],
    latitude = st_coordinates(spatial_combined)[,2]
  ) |>
  st_drop_geometry()

# Visualisation
ggplot(spatial_coords, aes(longitude, latitude, color = set)) +
  geom_point(size = 2.5, alpha = 0.7) +
  scale_color_manual(values = c("Train" = "#6BAED6", "Test" = "#F17D52")) +
  labs(
    title = "Split spatial avec clustering",
    x = "Longitude",
    y = "Latitude",
    subtitle = "Les clusters géographiques sont séparés entre train et test",
    color = NULL
  ) +
  coord_quickmap()

Les points d’une même zone géographique restent ensemble dans le train ou le test, évitant ainsi le problème d’autocorrélation spatiale.

NoteSplit en 2 folds pour la démonstration

spatialsample est conçu pour la validation croisée (où on crée plusieurs splits équilibrés). Ici, on utilise v = 2 pour simuler un simple train/test, ce qui donne un découpage basé sur les clusters spatiaux (pas forcément 50/50).

Dans la vraie vie, vous utiliseriez plutôt v = 5 ou v = 10 pour faire de la validation croisée spatiale, ce qui vous donne une évaluation plus robuste de votre modèle. On verra ça dans le tutoriel dédié à la validation croisée.

Astucespatialsample pour l’écologie

Si vos données ont des coordonnées géographiques (latitude/longitude, parcelles forestières…), utilisez toujours spatialsample au lieu de rsample pour éviter de surestimer vos performances.

Deux méthodes principales :

  • spatial_clustering_cv() : clustering automatique ;
  • spatial_block_cv() : découpage en blocs réguliers.

Le split spatial gère naturellement les structures groupées (sites, parcelles, îles…) en les séparant géographiquement. Pas besoin de faire un split manuel par groupe si vous utilisez déjà spatialsample.

Erreurs fréquentes et bonnes pratiques

Trois pièges à éviter

1. Standardiser avant de splitter

# MAUVAIS : fuite de données
data_scaled <- penguins_clean |>
  mutate(across(where(is.numeric), scale)) # Utilise mean/sd de TOUT le jeu (train + test)
split <- initial_split(data_scaled)

Pourquoi c’est problématique ? Si vous standardisez avant de splitter, vous utilisez des informations du test set (sa moyenne et son écart-type) pour transformer les données. C’est une fuite de données subtile qui biaise vos résultats.

# BON : standardiser séparément
# 1. D'abord splitter les données brutes
split <- initial_split(penguins_clean, strata = species)
train <- training(split)
test <- testing(split)

# 2. Identifier les variables numériques à standardiser
num_vars <- train |> select(where(is.numeric)) |> names()

# 3. Calculer mean/sd UNIQUEMENT sur le train
train_params <- train |>
  summarise(across(
    all_of(num_vars),
    list(mean = mean, sd = sd),  # Calcule moyenne et écart-type
    .names = "{.col}_{.fn}"       # Nomme les colonnes : var_mean, var_sd
  ))

# 4. Fonction pour standardiser : (x - mean) / sd
standardize <- function(data, params, vars) {
  data |>
    mutate(across(
      all_of(vars),
      ~ (. - params[[paste0(cur_column(), "_mean")]]) /  # Soustrait la moyenne du train
        params[[paste0(cur_column(), "_sd")]]            # Divise par l'écart-type du train
    ))
}

# 5. Appliquer les MÊMES paramètres (du train) au train ET au test
train_scaled <- standardize(train, train_params, num_vars)
test_scaled <- standardize(test, train_params, num_vars)  # Utilise les stats du TRAIN
Noterecipes automatise tout ça

En pratique, on utilise le package recipes de tidymodels qui (entre autres) gère automatiquement cette logique (calcul des paramètres sur train, application sur test). On verra ça dans un prochain tutoriel dédié au preprocessing.

2. Test set trop petit pour une classe minoritaire

# Simulons un dataset déséquilibré  
set.seed(123)
chinstrap_sample <- penguins_clean |> 
  filter(species == "Chinstrap") |> 
  slice_sample(n = 15)
other_species <- penguins_clean |> 
  filter(species != "Chinstrap")
imbalanced_penguins <- bind_rows(chinstrap_sample, other_species)

# Split stratifié 70/30
split_imbalanced <- initial_split(imbalanced_penguins, prop = 0.7, strata = species)

# Combien de Chinstrap dans le test ?
test_imbalanced <- testing(split_imbalanced)
table(test_imbalanced$species)

   Adelie Chinstrap    Gentoo 
       43         8        34 

Avec seulement 8 Chinstrap dans le test set, peut-on vraiment évaluer la performance du modèle sur cette classe ? Non, c’est trop peu pour être statistiquement fiable.

Ici, une validation croisée permettrait d’évaluer le modèle sur tous les individus de la classe minoritaire tour à tour. On en parlera dans un prochain tutoriel.

3. Ignorer l’autocorrélation

C’est l’erreur la plus fréquente en écologie et océanographie ! Un split aléatoire sur des données spatiales ou temporelles donne des résultats beaucoup trop optimistes.

Utilisez toujours initial_time_split() pour les séries temporelles et spatialsample pour les données géolocalisées.

Guide de décision

Type de données Principe Package
Données standard Split aléatoire stratifié rsample
Séries temporelles Split chronologique rsample
Données spatiales Clustering géographique spatialsample
Petit jeu (<500 obs) Validation croisée1 rsample

Pour aller plus loin

Dans les prochains tutoriels de cette série :

  • validation croisée : k-fold, leave-one-out, nested CV
  • recipes : preprocessing et feature engineering

Notes de bas de page

  1. dans un prochain tutoriel !↩︎