Leçon 4 sur 8
Unité · La même réponse deux fois
Quatre choses qui changent la réponse alors que rien n'a changé
L'horloge, la graine, l'ordre des fichiers et la locale. Chacune produit un résultat différent depuis un code identique et des données identiques, chacune est invisible dans un diff, et cette plateforme en a rencontré trois sur quatre en production.
Un : l’horloge
from datetime import date
report_date = date.today() # different every day, by design
Tout ce qui lit l’heure courante fait différer chaque exécution. Un pied de page « produit le », un nom de fichier avec la date du jour, un filtre sur « les 30 derniers jours », une graine aléatoire dérivée du temps.
Le remède est de prendre la date des données ou d’un paramètre.
import pandas as pd
survey = pd.read_csv("data/raw/household-survey-2025.v1.csv")
as_of = pd.to_datetime(survey["interview_date"]).max() # from the data
as_of <- max(as.Date(survey$interview_date))
Cette plateforme s’est trompée là-dessus et l’échec est instructif. Les documents
de cours et les diaporamas de leçon prenaient à l’origine leur date de production du
champ updated le plus récent de toute la bibliothèque de contenu. Déterministe — et
faux quand même, parce que publier un nouveau cours réhorodatait chaque document
existant et réécrivait les quatre-vingt-seize binaires de diaporama.
La date est désormais rattachée à l’artefact. Celle d’un document vient de son propre cours et de ses leçons, celle d’un diaporama de sa propre leçon, celle d’un rapport de son propre projet. Une réponse fausse déterministe reste une réponse fausse, et « le diff est énorme mais correct » est la façon dont personne ne le relit.
Deux : la graine
import numpy as np
rng = np.random.default_rng(20260729) # stated, committed, reproducible
sample = rng.choice(households, size=200, replace=False)
set.seed(20260729)
sample(households, 200)
Tout ce qui est aléatoire exige une graine et la graine a sa place dans le code. Intervalles bootstrap, échantillonnage aléatoire pour vérification, simulation, partitions d’apprentissage et de test, points de nuage déplacés aléatoirement.
Fixez-la une fois, en tête, visiblement. Une graine enfouie trois fonctions plus bas est une graine que quelqu’un déplacera.
Et dites-le dans la sortie. L’intervalle bootstrap du cours de régression est rapporté avec « 400 rééchantillonnages, graine 20260729 » exactement pour cette raison — un intervalle que personne ne peut reproduire n’est pas un intervalle.
Chaque jeu de données de cette plateforme est produit par un script à graine fixe,
ce qui fait de pnpm datasets:generate une étape de vérification : il réécrit les
vingt CSV et git status ne signale aucun changement.
Trois : l’ordre dans lequel les fichiers reviennent
import glob
for path in glob.glob("data/raw/*.csv"): # order is filesystem-dependent
...
glob et os.listdir renvoient les fichiers dans un ordre qui diffère selon les
systèmes et parfois d’une exécution à l’autre. Si quoi que ce soit en aval dépend de
l’ordre — une concaténation, une déduplication au premier arrivé, un index de ligne —
le résultat diffère.
for path in sorted(glob.glob("data/raw/*.csv")):
...
for (path in sort(list.files("data/raw", full.names = TRUE))) { }
Triez. Toujours. Cela coûte six caractères.
La même chose vaut pour l’ordre des dictionnaires et des groupes. groupby dans
pandas trie par défaut et dplyr::group_by non ; un graphique dont l’ordre des barres
vient d’un groupement non trié est un graphique dont l’ordre peut changer sans que les
données changent.
Quatre : la locale
float("1,234") # ValueError, or 1.234, depending on where you are
Le séparateur décimal, le séparateur de milliers, le format de date et l’ordre de tri des caractères accentués dépendent tous de la locale, et le public de cette plateforme travaille dans les deux conventions par définition.
survey = pd.read_csv(path, decimal=".", thousands=None) # stated, not inferred
dates = pd.to_datetime(survey["date"], format="%Y-%m-%d") # explicit
readr::read_csv(path, locale = locale(decimal_mark = ".", date_format = "%Y-%m-%d"))
Énoncez le format plutôt que de laisser le lecteur l’inférer. pd.to_datetime
sans format est une fonction qui devine, et elle devine différemment sur 03/04/2025
selon ce qu’il y a d’autre dans la colonne.
Le tri est le plus subtil. sorted() sur des noms de communes français place
Étroit après Zone sous une locale et avant Fond sous une autre, si bien que
l’ordre des catégories d’un graphique devient dépendant de la machine.
Le test qui attrape les quatre
python run.py && cp -r outputs outputs-first
python run.py && diff -r outputs outputs-first
Exécutez deux fois et comparez. Tout ce qui diffère est l’une des quatre, et le diff nomme le fichier.
Exécutez deux fois sur des machines différentes et vous attrapez en plus la locale et l’ordre des fichiers, ce qu’une seule machine ne peut pas faire.
Mettez-le dans l’intégration continue, qui est une machine propre à chaque fois, et la vérification s’exécute que quiconque y pense ou non.
L’habitude de la sortie déterministe
Pour tout ce qu’une compilation produit, une sortie identique octet pour octet sur entrée inchangée est l’objectif, et il est atteignable plus souvent qu’on ne le suppose.
| Source de bruit | Remède |
|---|---|
| Horodatages dans les métadonnées de fichier | SOURCE_DATE_EPOCH, ou les retirer |
| Une date de production dans un pied de page | La prendre du contenu |
| Une compression avec horodatage | Une archive dont les dates d’entrée sont fixées |
| L’ordre de sommation en virgule flottante | Trier avant de réduire là où cela compte |
| Un identifiant aléatoire incorporé | Le dériver d’une empreinte du contenu |
Cette plateforme fixe SOURCE_DATE_EPOCH pour pdfTeX et pour pandoc, parce qu’un
.pptx est une archive dont les dates d’entrée et les propriétés de document
bougeraient sinon à chaque reconstruction de chaque diaporama.
Le bénéfice est qu’un diff signifie quelque chose. Quand reconstruire ne produit aucun changement, un changement dans le diff est un vrai changement — et la relecture devient possible.
Rapportez-le en entier
Determinism
All randomness is seeded: seed 20260729, set in src/config.py and reported
with every bootstrap interval in this report.
Dates are taken from the data (the latest interview date) rather than from
the clock. No output contains a generation timestamp.
File iteration is sorted. Group order is stated explicitly rather than
inherited from the grouping library's default.
CSV reading states the decimal mark and the date format rather than
inferring them.
Verified: running the pipeline twice produces byte-identical outputs, and
CI runs it on a clean machine on every push.
La dernière ligne est l’affirmation et les quatre au-dessus sont la façon dont elle a été obtenue. Un rapport qui affirme le déterminisme sans dire laquelle des quatre il a traitée a probablement traité la graine et aucune autre.
La suite
Une chaîne reproductible qui produit un rapport vaut la peine d’exister. La leçon suivante lui en fait produire douze, depuis un seul modèle, sans une seule copie.