Techniques de base en anonymisation de données

Démonstrations en R avec un jeu de données fictif

Objectifs du document

  • Comprendre la différence entre anonymisation et pseudonymisation.
  • Repérer les identifiants directs, quasi-identifiants et variables sensibles dans un jeu de données.
  • Mettre en œuvre, en R, un pipeline simple d’anonymisation combinant plusieurs techniques.
  • Discuter le compromis entre protection de la vie privée et utilité statistique.

⚠️ Ce document a une vocation pédagogique. Il ne remplace pas un avis juridique ni les lignes directrices officielles de votre organisme.

1. Contexte et définitions

On distinguera dans la suite :

  • Donnée personnelle : information se rapportant à une personne physique identifiée ou identifiable.
  • Anonymisation : transformation de données personnelles de façon à ce qu’il soit raisonnablement impossible d’identifier une personne, directement ou indirectement, de manière irréversible.
  • Pseudonymisation : remplacement des identifiants directs par des codes (pseudonymes). La ré-identification reste possible si l’on dispose de la clé de correspondance.
  • Identifiants directs : nom, prénom, adresse postale complète, numéro d’assurance maladie, etc.
  • Quasi-identifiants : variables qui, combinées, peuvent identifier une personne (âge, sexe, code postal, date d’hospitalisation, etc.).
  • Variables sensibles : diagnostics médicaux, revenus, résultats scolaires, etc.

Dans la pratique, un processus d’anonymisation combine en général :

  1. le filtrage ou la transformation des identifiants directs ;
  2. la réduction d’information sur les quasi-identifiants (généralisation, suppression, agrégation) ;
  3. éventuellement l’ajout de bruit ou la publication d’agrégats plutôt que de micro-données.

2. Création et exploration du jeu de données fictif

Nous créons un petit jeu de données de patients fictifs d’un hôpital.

library(tidyverse)

set.seed(123)

donnees <- tibble(
  nom = c("Martin", "Gagnon", "Tremblay", "Roy", "Ouellet",
          "Bouchard", "Lavoie", "Fortin"),
  prenom = c("Anne", "Louis", "Sophie", "Marc", "Julie",
             "Pierre", "Marie", "Luc"),
  sexe = factor(c("F", "M", "F", "M", "F", "M", "F", "M")),
  age = c(34, 51, 28, 79, 45, 38, 60, 52),
  code_postal = c("G1K 7P4", "G1V 1A2", "G1C 3T8", "G1B 4S9",
                  "G1X 2P1", "G1S 2B3", "G1P 4M2", "G1E 5T7"),
  diagnostic = c("Diabète", "Hypertension", "COVID-19", "Asthme",
                 "Diabète", "Cancer", "Hypertension", "Dépression"),
  poids = c(62, 81, 70, 74, 55, 90, 68, 80)
)

donnees
# A tibble: 8 × 7
  nom      prenom sexe    age code_postal diagnostic   poids
  <chr>    <chr>  <fct> <dbl> <chr>       <chr>        <dbl>
1 Martin   Anne   F        34 G1K 7P4     Diabète         62
2 Gagnon   Louis  M        51 G1V 1A2     Hypertension    81
3 Tremblay Sophie F        28 G1C 3T8     COVID-19        70
4 Roy      Marc   M        79 G1B 4S9     Asthme          74
5 Ouellet  Julie  F        45 G1X 2P1     Diabète         55
6 Bouchard Pierre M        38 G1S 2B3     Cancer          90
7 Lavoie   Marie  F        60 G1P 4M2     Hypertension    68
8 Fortin   Luc    M        52 G1E 5T7     Dépression      80

On identifie les rôles des variables :

id_directs <- c("nom", "prenom")
quasi_id   <- c("age", "sexe", "code_postal")
sensibles  <- c("diagnostic")

list(
  identifiants_directs = id_directs,
  quasi_identifiants   = quasi_id,
  variables_sensibles  = sensibles
)
$identifiants_directs
[1] "nom"    "prenom"

$quasi_identifiants
[1] "age"         "sexe"        "code_postal"

$variables_sensibles
[1] "diagnostic"

3. Étape 1 : suppression des identifiants directs

Les identifiants directs sont retirés avant tout partage externe.

donnees_sans_id <- donnees %>%
  select(-all_of(id_directs))

donnees_sans_id
# A tibble: 8 × 5
  sexe    age code_postal diagnostic   poids
  <fct> <dbl> <chr>       <chr>        <dbl>
1 F        34 G1K 7P4     Diabète         62
2 M        51 G1V 1A2     Hypertension    81
3 F        28 G1C 3T8     COVID-19        70
4 M        79 G1B 4S9     Asthme          74
5 F        45 G1X 2P1     Diabète         55
6 M        38 G1S 2B3     Cancer          90
7 F        60 G1P 4M2     Hypertension    68
8 M        52 G1E 5T7     Dépression      80

💬 Commentaire

  • Protection : on ne peut plus retrouver immédiatement la personne à partir de son nom ou prénom.
  • Limite : les quasi-identifiants (âge, sexe, code postal) peuvent encore isoler une personne.

4. Étape 2 : pseudonymisation

On ajoute un identifiant artificiel qui remplace nom/prénom dans les analyses internes.

donnees_pseudo <- donnees %>%
  mutate(id_pseudo = paste0("P", sample(1001:9999, n(), replace = FALSE))) %>%
  select(id_pseudo, everything(), -all_of(id_directs))

donnees_pseudo
# A tibble: 8 × 6
  id_pseudo sexe    age code_postal diagnostic   poids
  <chr>     <fct> <dbl> <chr>       <chr>        <dbl>
1 P3463     F        34 G1K 7P4     Diabète         62
2 P3511     M        51 G1V 1A2     Hypertension    81
3 P9718     F        28 G1C 3T8     COVID-19        70
4 P3986     M        79 G1B 4S9     Asthme          74
5 P2842     F        45 G1X 2P1     Diabète         55
6 P4371     M        38 G1S 2B3     Cancer          90
7 P5761     F        60 G1P 4M2     Hypertension    68
8 P7746     M        52 G1E 5T7     Dépression      80

💬 Commentaire

  • Avantage : on garde une clé stable pour suivre un individu dans le temps (utile en recherche).
  • Limite : ce n’est pas de l’anonymisation au sens strict.
    Une table de correspondance id_pseudo ↔︎ nom, prénom existe quelque part.

En pratique, cette table de correspondance doit être :

  • stockée séparément,
  • chiffrée,
  • accessible seulement à un nombre restreint de personnes.

5. Étape 3 : analyse du risque de réidentification

Même sans nom, certaines combinaisons (âge, sexe, code_postal) peuvent être uniques.

risque_quasi_id <- donnees_pseudo %>%
  count(age, sexe, code_postal, name = "taille_groupe") %>%
  arrange(taille_groupe)

risque_quasi_id
# A tibble: 8 × 4
    age sexe  code_postal taille_groupe
  <dbl> <fct> <chr>               <int>
1    28 F     G1C 3T8                 1
2    34 F     G1K 7P4                 1
3    38 M     G1S 2B3                 1
4    45 F     G1X 2P1                 1
5    51 M     G1V 1A2                 1
6    52 M     G1E 5T7                 1
7    60 F     G1P 4M2                 1
8    79 M     G1B 4S9                 1

On peut résumer le niveau minimal de protection (idée de k-anonymat : chaque combinaison de quasi-identifiants apparaît au moins k fois).

k_observe <- min(risque_quasi_id$taille_groupe)
k_observe
[1] 1

Si k_observe = 1, au moins une personne est unique sur ses quasi-identifiants → risque de réidentification élevé.


6. Étape 4 : généralisation des quasi-identifiants

Nous réduisons la précision :

  • âge → tranches d’âge,
  • code postal → secteur (3 premiers caractères).
donnees_gen <- donnees_pseudo %>%
  mutate(
    age_tranche = cut(
      age,
      breaks = c(0, 30, 50, 100),
      labels = c("0-30", "31-50", "51+"),
      right = TRUE
    ),
    secteur = str_sub(code_postal, 1, 3)
  ) %>%
  select(id_pseudo, age_tranche, sexe, secteur, diagnostic, poids)

donnees_gen
# A tibble: 8 × 6
  id_pseudo age_tranche sexe  secteur diagnostic   poids
  <chr>     <fct>       <fct> <chr>   <chr>        <dbl>
1 P3463     31-50       F     G1K     Diabète         62
2 P3511     51+         M     G1V     Hypertension    81
3 P9718     0-30        F     G1C     COVID-19        70
4 P3986     51+         M     G1B     Asthme          74
5 P2842     31-50       F     G1X     Diabète         55
6 P4371     31-50       M     G1S     Cancer          90
7 P5761     51+         F     G1P     Hypertension    68
8 P7746     51+         M     G1E     Dépression      80

On réévalue les tailles de groupes :

risque_gen <- donnees_gen %>%
  count(age_tranche, sexe, secteur, name = "taille_groupe") %>%
  arrange(taille_groupe)

risque_gen
# A tibble: 8 × 4
  age_tranche sexe  secteur taille_groupe
  <fct>       <fct> <chr>           <int>
1 0-30        F     G1C                 1
2 31-50       F     G1K                 1
3 31-50       F     G1X                 1
4 31-50       M     G1S                 1
5 51+         F     G1P                 1
6 51+         M     G1B                 1
7 51+         M     G1E                 1
8 51+         M     G1V                 1

Et le nouveau k observé :

k_observe_gen <- min(risque_gen$taille_groupe)
k_observe_gen
[1] 1

💬 Commentaire

  • Si k_observe_gen est plus grand que k_observe, la situation a été améliorée.
  • Plus on généralise, plus on perd de précision pour les analyses, mais plus on protège les individus.

7. Étape 5 : perturbation des variables numériques

Nous ajoutons un bruit gaussien à la variable poids.

donnees_perturbees <- donnees_gen %>%
  mutate(
    poids_bruit = poids + rnorm(n(), mean = 0, sd = 2)
  ) %>%
  select(-poids)

donnees_perturbees
# A tibble: 8 × 6
  id_pseudo age_tranche sexe  secteur diagnostic   poids_bruit
  <chr>     <fct>       <fct> <chr>   <chr>              <dbl>
1 P3463     31-50       F     G1K     Diabète             64.6
2 P3511     51+         M     G1V     Hypertension        77.5
3 P9718     0-30        F     G1C     COVID-19            73.4
4 P3986     51+         M     G1B     Asthme              75.0
5 P2842     31-50       F     G1X     Diabète             60.1
6 P4371     31-50       M     G1S     Cancer              91.1
7 P5761     51+         F     G1P     Hypertension        68.5
8 P7746     51+         M     G1E     Dépression          77.9

Vérifions que les statistiques globales sont peu modifiées.

moyennes <- tibble(
  jeu = c("avant_bruit", "apres_bruit"),
  poids_moyen = c(
    mean(donnees_gen$poids),
    mean(donnees_perturbees$poids_bruit)
  )
)

moyennes
# A tibble: 2 × 2
  jeu         poids_moyen
  <chr>             <dbl>
1 avant_bruit        72.5
2 apres_bruit        73.5

💬 Commentaire

  • Si l’écart type du bruit est petit, les analyses globales (moyennes, régressions simples) restent proches de la réalité.
  • En revanche, il devient plus difficile de revenir à la valeur exacte d’un individu.

8. Étape 6 : agrégation (publication de statistiques)

Souvent, on ne diffuse pas les micro-données, mais seulement des statistiques agrégées.

donnees_agregees <- donnees_perturbees %>%
  group_by(diagnostic, age_tranche) %>%
  summarise(
    n = n(),
    poids_moy = mean(poids_bruit),
    .groups = "drop"
  )

donnees_agregees
# A tibble: 6 × 4
  diagnostic   age_tranche     n poids_moy
  <chr>        <fct>       <int>     <dbl>
1 Asthme       51+             1      75.0
2 COVID-19     0-30            1      73.4
3 Cancer       31-50           1      91.1
4 Diabète      31-50           2      62.3
5 Dépression   51+             1      77.9
6 Hypertension 51+             2      73.0

💬 Commentaire

  • Niveau de protection très élevé : on ne voit plus aucune ligne individuelle.
  • Utilité : bonne pour des analyses descriptives, plus limitée pour les modèles complexes sur données individuelles.

9. Synthèse des versions du jeu de données

tibble(
  nom_objet = c(
    "donnees",
    "donnees_sans_id",
    "donnees_pseudo",
    "donnees_gen",
    "donnees_perturbees",
    "donnees_agregees"
  ),
  description = c(
    "Jeu de données original, nominatif.",
    "Identifiants directs retirés, quasi-identifiants encore très précis.",
    "Pseudonymisation avec un identifiant artificiel.",
    "Généralisation des quasi-identifiants.",
    "Généralisation + bruit sur le poids.",
    "Statistiques agrégées par diagnostic et tranche d'âge."
  )
)
# A tibble: 6 × 2
  nom_objet          description
  <chr>              <chr>
1 donnees            Jeu de données original, nominatif.
2 donnees_sans_id    Identifiants directs retirés, quasi-identifiants encore tr…
3 donnees_pseudo     Pseudonymisation avec un identifiant artificiel.
4 donnees_gen        Généralisation des quasi-identifiants.
5 donnees_perturbees Généralisation + bruit sur le poids.
6 donnees_agregees   Statistiques agrégées par diagnostic et tranche d'âge.     

On peut relier chaque version à un niveau (très simplifié) de protection et d’utilité :

Version Protection vie privée Utilité pour l’analyse individuelle
donnees Très faible Très élevée
donnees_sans_id Faible Très élevée
donnees_pseudo Faible à modérée Très élevée
donnees_gen Modérée Élevée
donnees_perturbees Modérée à élevée Moyenne à élevée
donnees_agregees Très élevée Faible pour les analyses individuelles

10. Checklist pratique (version simple)

Avant de partager un jeu de données contenant des personnes :

  1. Lister les variables et les classer en identifiants directs, quasi-identifiants, variables sensibles.
  2. Retirer les identifiants directs du jeu qui sera partagé.
  3. Décider du niveau de généralisation acceptable (tranches d’âge, agrégation géographique, etc.).
  4. Évaluer le risque de réidentification (par exemple, vérifier si des combinaisons de quasi-identifiants sont uniques).
  5. Ajouter du bruit si nécessaire sur certaines variables numériques.
  6. Se demander si la diffusion d’agrégats serait suffisante pour l’objectif.
  7. Documenter toutes les transformations appliquées et conserver cette documentation dans le dossier de projet.
  8. Vérifier la conformité avec les politiques internes et le cadre légal pertinent avant diffusion.

11. Références (pour aller plus loin)

  • CNIL. Anonymisation of personal data: definitions and techniques. 2020.
  • Commission d’accès à l’information du Québec. Règlement sur l’anonymisation des renseignements personnels. 2024.
  • Norton Rose Fulbright. Anonymisation des renseignements personnels : comment faire pour procéder en toute conformité. 2024.
  • Sweeney, L. k-anonymity: a model for protecting privacy. International Journal of Uncertainty, Fuzziness and Knowledge-Based Systems, 2002.
  • El Emam, K. Protecting Privacy Using k-Anonymity. Journal of the American Medical Informatics Association, 2008.