Python moderne : typage, décorateurs, itérateurs et outillage
Ce module ne réapprend pas Python : il installe l'environnement de travail d'un projet de data science, et surtout il explique en détail quatre mécanismes du langage qu'on croise partout dans le code scientifique sans forcément les avoir jamais étudiés — les décorateurs, les dataclasses, les annotations de type et les générateurs — auxquels s'ajoutent, en chemin, les méthodes spéciales et la notation *args / **kwargs. Chacun répond à un problème concret, et je pars du problème.
📋 Au programme
1. Structure d'un projet Python
Le problème
Un projet de data science commence toujours de la même façon : un notebook, quelques cellules, un résultat. Puis on veut relancer l'analyse sur de nouvelles données, et on s'aperçoit que le résultat dépendait de l'ordre dans lequel les cellules ont été exécutées, qu'une variable a été modifiée à la main entre deux essais, et que la fonction de nettoyage existe en trois versions dans trois notebooks différents. Le code n'est plus reproductible, et personne ne sait laquelle des trois versions a produit le modèle en cours d'utilisation.
La discipline qui évite cela tient en une phrase : dès qu'un bout de code est appelé deux fois, il quitte le notebook et devient un module importable. Le notebook reste ce qu'il est — un brouillon interactif, excellent pour regarder les données — et importe la logique au lieu de la contenir.
La disposition standard
mon-projet/ ├── pyproject.toml # identité du projet, dépendances, config des outils ├── uv.lock # versions exactes installées (versionné dans Git) ├── README.md ├── src/ │ └── mon_projet/ # le paquet importable : import mon_projet │ ├── __init__.py # marque le dossier comme paquet (peut être vide) │ ├── data.py # chargement / nettoyage │ ├── features.py # transformations │ └── models.py # entraînement / inférence ├── notebooks/ # exploration ; aucune logique réutilisable ici ├── tests/ └── data/ # ignoré par Git (voir section 8)
Pourquoi ce dossier src/ intermédiaire, qui semble n'ajouter qu'un niveau de profondeur ? Parce que sans lui, Python trouve le paquet simplement parce qu'il se trouve dans le répertoire courant. On peut alors avoir des tests qui passent alors que le paquet n'est en réalité pas installable — l'erreur n'apparaît que chez quelqu'un d'autre. Avec src/, le paquet n'est importable que s'il a été réellement installé dans l'environnement : ce qu'on teste est exactement ce qu'on distribuera.
Le fichier pyproject.toml
C'est la carte d'identité du projet, au format TOML (un format de configuration lisible, à mi-chemin entre INI et JSON). Il a remplacé l'ancien trio setup.py / setup.cfg / requirements.txt, et il centralise aussi la configuration des outils de qualité.
[project]
name = "mon-projet" # nom de distribution (tirets autorisés)
version = "0.1.0"
requires-python = ">=3.12" # versions de Python acceptées
dependencies = [ # ce dont le projet a besoin pour FONCTIONNER
"numpy>=2.0",
"polars>=1.0",
"scikit-learn>=1.5",
]
[dependency-groups] # ce dont on a besoin pour DÉVELOPPER
dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.11", "pre-commit>=3.8"]
[build-system] # comment fabriquer le paquet installable
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.ruff] # configuration du linter/formateur
line-length = 100
[tool.pytest.ini_options] # configuration des tests
testpaths = ["tests"]La distinction entre dependencies et le groupe dev a une conséquence concrète : l'image Docker de production (module 5) n'installe que les premières, ce qui la rend plus légère et réduit sa surface d'attaque.
Le système de build : à quoi sert hatchling ?
La section [build-system] est celle qu'on recopie sans la comprendre. Elle mérite trois minutes, parce qu'elle explique une bonne partie de l'écosystème Python.
Le problème. Un projet, sur le disque, est un répertoire de fichiers .py. Pour l'installer ailleurs — sur le serveur, dans l'image Docker, chez un collègue —, il faut le transformer en paquet : une archive normalisée, avec ses métadonnées (nom, version, dépendances), que pip ou uv sait déposer au bon endroit. Cette transformation s'appelle la construction (build), et elle produit deux formats :
mon_projet-0.1.0-py3-none-any.whl ← la WHEEL : archive PRÊTE À POSER
· rien à exécuter, pip décompresse et c'est installé
· py3-none-any = n'importe quel Python 3, n'importe quel OS
· pour un paquet contenant du C compilé, on aurait par exemple
cp312-manylinux_x86_64 : une wheel par plateforme
mon_projet-0.1.0.tar.gz ← la SDIST : archive des SOURCES
· contient le code source, il faut construire avant d'installer
· sert de repli quand aucune wheel ne correspond à la machinePourquoi un « backend » séparé. Historiquement, construire un paquet consistait à lancer python setup.py install : un script Python arbitraire, que l'installeur exécutait en croisant les doigts. Impossible de savoir à l'avance ce dont il avait besoin, ni ce qu'il allait faire. Deux propositions d'amélioration (les PEP 517 et 518) ont coupé le problème en deux :
VOUS uv pip install .
│
▼
FRONTEND pip / uv / build « je veux installer ce projet »
│ il lit [build-system], installe
│ ce qu'il demande dans un
│ environnement isolé, puis
▼ l'appelle par une interface standard
BACKEND hatchling / setuptools « voici la wheel et la sdist »
│ lui seul sait COMMENT assembler
▼ le paquet
ARTEFACTS dist/*.whl dist/*.tar.gz
Le frontend ne sait rien du backend, et réciproquement : c'est ce
découplage qui permet d'en changer en modifiant deux lignes.C'est exactement ce que déclarent les deux lignes du pyproject.toml : requires dit ce que le frontend doit installer pour pouvoir construire, build-backend dit quelle fonction appeler ensuite.
Les backends que vous rencontrerez
hatchling requires = ["hatchling"]
build-backend = "hatchling.build"
Le défaut moderne, et celui que pose « uv init ». Zéro configuration
pour un projet Python pur, versionnage dynamique, inclusion de fichiers
simple à déclarer. C'est le choix par défaut à ne pas remettre en cause
tant qu'on n'a pas une raison précise.
setuptools requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
L'historique, toujours le plus répandu. Indispensable si le projet
contient des extensions C à compiler à l'ancienne, ou si l'on hérite
d'un setup.py existant. Plus verbeux, mais rien ne lui résiste.
poetry-core requires = ["poetry-core>=1.9"]
build-backend = "poetry.core.masonry.api"
Le backend de Poetry. On le croise dans les projets gérés par Poetry ;
il impose sa propre section [tool.poetry] plutôt que [project].
flit-core requires = ["flit_core>=3.9"]
build-backend = "flit_core.buildapi"
Minimaliste, pour une bibliothèque d'un seul module sans dépendance
de construction. Rien à configurer, rien à régler.
pdm-backend requires = ["pdm-backend"]
build-backend = "pdm.backend"
Proche de hatchling, avec des crochets d'extension en Python.
── quand le projet n'est PAS que du Python ──
maturin pour une extension écrite en Rust (pydantic-core,
polars et ruff sont construits ainsi)
scikit-build-core pour du C/C++ avec CMake
meson-python pour du C/Fortran avec Meson (NumPy, SciPy)uv build # produit dist/*.whl et dist/*.tar.gz
ls dist/
# mon_projet-0.1.0-py3-none-any.whl mon_projet-0.1.0.tar.gz
unzip -l dist/*.whl # une wheel est un simple fichier ZIP :
# on peut vérifier ce qu'elle contient vraiment
uv pip install dist/*.whl # installer le paquet construit
uv publish # le pousser sur PyPI (ou un dépôt interne)Et si mon projet n'est pas une bibliothèque ?
Beaucoup de projets de data science ne sont jamais installés : on lance des scripts depuis la racine du dépôt, point. Dans ce cas, aucun backend n'est nécessaire, et on le dit explicitement :
[tool.uv]
package = false # projet « applicatif » : uv gère l'environnement
# et les dépendances, mais ne construit rienL'avantage de rester tout de même un paquet, même pour un projet interne, est l'import : avec un paquet installé en mode éditable, from mon_projet.features import construire fonctionne depuis n'importe quel répertoire, y compris depuis un notebook et depuis les tests. Sans cela, on retombe sur les bricolages à base de sys.path.append(".."), qui cassent dès qu'on déplace un fichier. Pour le coût de six lignes de pyproject.toml, le second choix est presque toujours le bon.
2. Environnements virtuels et UV
Pourquoi un environnement virtuel
Un seul Python est installé sur la machine, avec un seul jeu de bibliothèques. Deux projets qui ont besoin de versions différentes de pandas ne peuvent donc pas coexister : installer l'un casse l'autre. Un environnement virtuel résout cela en créant un dossier — par convention .venv/ — qui contient sa propre copie de l'interpréteur et ses propres bibliothèques. Chaque projet a son atelier, avec ses outils à lui, et rien de ce qu'on y installe ne déborde sur les autres.
UV en pratique
UV (écrit en Rust par Astral, les auteurs de ruff) remplace à lui seul pip, virtualenv, pyenv, pipx et poetry. Deux différences pratiques : il est un à deux ordres de grandeur plus rapide, et il sait aussi installer les interpréteurs Python eux-mêmes — on n'a plus à se soucier de la version fournie par le système.
# créer un projet : génère pyproject.toml, .venv/ et uv.lock uv init mon-projet && cd mon-projet # installer une version précise de Python, sans toucher au Python système uv python install 3.12 uv python pin 3.12 # fige la version pour ce projet # ajouter une dépendance : résolution + installation + mise à jour du lock uv add "polars>=1.0" scikit-learn uv add --dev pytest ruff mypy pre-commit uv remove scikit-learn # retirer proprement # recréer à l'identique l'environnement d'un collègue, ou celui de la CI uv sync # installe EXACTEMENT ce que dit uv.lock uv sync --no-dev # sans les outils de développement (production) # exécuter dans l'environnement, sans avoir à l'activer uv run python -m mon_projet.train uv run pytest uv run ruff check . # mettre à jour volontairement uv lock --upgrade-package polars # script autonome, dépendances déclarées dans son propre en-tête (PEP 723) uv run --with pandas,matplotlib analyse.py
Contraintes et lockfile : la différence qui compte
Deux fichiers, deux rôles distincts. Le pyproject.toml déclare des contraintes — « numpy au moins en 2.0 » — c'est-à-dire une intention. Le uv.lock enregistre la solution exacte retenue par le résolveur — numpy 2.1.3, avec l'empreinte cryptographique de chaque fichier téléchargé — c'est-à-dire un état reproductible. Les deux se versionnent dans Git. Résultat : uv sync installe les mêmes octets sur votre machine, sur celle d'un collègue et dans la chaîne d'intégration continue, aujourd'hui comme dans six mois.
C'est la différence avec un requirements.txt produit par pip freeze : celui-ci fige les versions d'une seule plateforme, mélange dépendances directes et indirectes, et ne dit pas lesquelles vous avez demandées.
[tool.uv.sources] vers le dépôt cu124, par exemple), mais l'environnement « identique » s'arrête à la frontière de la machine. C'est une des raisons de passer à Docker dès qu'un GPU entre en jeu (module 5).
Et conda ? Il reste utile quand on dépend de bibliothèques système non-Python compliquées à compiler (GDAL, certains solveurs, une MKL configurée finement). Pour un projet de ML standard, UV est plus rapide, plus simple, et son lockfile est nettement plus fiable qu'un environment.yml.
3. Les décorateurs, de zéro
Le problème qu'ils résolvent
Supposons que l'on veuille chronométrer trois fonctions d'entraînement. La solution naïve consiste à copier deux lignes de mesure au début et à la fin de chacune. C'est répétitif, ça mélange la mesure et la logique métier, et le jour où l'on veut aussi journaliser les arguments, il faut repasser partout.
Un décorateur est la réponse de Python à ce problème : une façon d'emballer une fonction dans une autre qui ajoute un comportement avant et après, sans modifier une seule ligne de la fonction d'origine. L'image est celle d'un colis qu'on emballe : le contenu ne change pas, mais tout ce qui passe par l'emballage peut être compté, tracé, mis en cache.
Un détour nécessaire : *args et **kwargs
Le code qui suit s'appuie sur deux notations qu'on croise partout en Python sans toujours les avoir vues expliquées. Elles font une seule chose, dans les deux sens : regrouper plusieurs arguments en un seul objet, ou au contraire répandre un objet en plusieurs arguments.
Côté définition : recevoir un nombre variable d'arguments
Une fonction ordinaire impose sa signature : trois paramètres, trois valeurs. L'étoile lève cette contrainte.
def inspecter(*args, **kwargs):
print("args :", args) # un TUPLE des arguments POSITIONNELS
print("kwargs :", kwargs) # un DICTIONNAIRE des arguments NOMMÉS
inspecter(1, 2, 3, alpha=0.1, verbose=True)
# args : (1, 2, 3)
# kwargs : {'alpha': 0.1, 'verbose': True}
inspecter()
# args : ()
# kwargs : {} # jamais None : toujours un tuple et un dict
# les noms sont une convention, pas une règle : seules les étoiles comptent.
# def f(*valeurs, **options) fonctionne exactement pareil — mais tout le
# monde écrit args et kwargs, alors autant faire comme tout le monde.L'ordre des paramètres est imposé : d'abord les paramètres normaux, puis *args, puis les paramètres nommés uniquement, puis **kwargs.
def entrainer(donnees, *args, epochs=10, **kwargs):
...
entrainer(X, "extra", epochs=50, learning_rate=0.01)
# donnees = X (positionnel normal)
# args = ("extra",)
# epochs = 50 (nommé, capté par son nom AVANT d'atteindre kwargs)
# kwargs = {"learning_rate": 0.01}Côté appel : répandre un conteneur en arguments
Les mêmes étoiles, placées à l'appel, font l'opération inverse : elles déballent. C'est l'usage le plus utile au quotidien.
def rmse(y_vrai, y_pred, poids=None):
...
paire = (y_vrai, y_pred)
rmse(*paire) # équivaut à rmse(y_vrai, y_pred)
params = {"y_vrai": y, "y_pred": p, "poids": w}
rmse(**params) # équivaut à rmse(y_vrai=y, y_pred=p, poids=w)
# l'usage qui revient sans cesse : une configuration lue dans un fichier,
# transmise telle quelle au constructeur du modèle
config = {"n_estimators": 500, "learning_rate": 0.05, "max_depth": 6}
modele = LGBMRegressor(**config) # au lieu de recopier chaque paramètre
# fusionner deux dictionnaires, les valeurs de droite gagnant
defauts = {"n_estimators": 300, "random_state": 42}
final = {**defauts, **config} # {'n_estimators': 500, 'random_state': 42, ...}Le cas qui justifie tout le détour : transmettre sans savoir
Quand on écrit une fonction qui en enveloppe une autre, on ignore la signature de celle qu'on enveloppe. *args, **kwargs permet d'accepter n'importe quoi et de le retransmettre intact. C'est précisément ce que fait un décorateur, et c'est aussi la manière habituelle d'écrire une petite surcouche autour d'une bibliothèque :
import matplotlib.pyplot as plt
def courbe(x, y, titre, **kwargs):
"""Trace avec notre style maison, tout en laissant passer
n'importe quelle option de matplotlib."""
fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(x, y, linewidth=2, **kwargs) # color=, linestyle=, alpha=, label=...
ax.set_title(titre)
ax.grid(alpha=0.3)
return ax
courbe(t, y, "Perte", color="crimson", linestyle="--")
# on n'a pas eu à prévoir color ni linestyle : ils traversent la fonctionDeux usages secondaires, mais fréquents
Une étoile seule comme séparateur. Écrite sans nom, elle force à nommer tout ce qui suit :
def decouper(X, y, *, test_size=0.2, stratifier=True):
...
decouper(X, y, 0.3) # TypeError : 3 positionnels pour 2 attendus
decouper(X, y, test_size=0.3) # ✓ obligatoirement nomméL'intérêt est la lisibilité durable : decouper(X, y, 0.3, True) ne dit rien au lecteur, et interdit d'ajouter un paramètre au milieu sans casser tous les appels existants. C'est la raison d'être de l'option kw_only des dataclasses (section 4), et scikit-learn impose cette règle sur la quasi-totalité de ses fonctions.
Le déballage hors des appels. L'étoile fonctionne aussi à l'affectation et dans les littéraux :
premier, *reste = [1, 2, 3, 4] # premier = 1 ; reste = [2, 3, 4] debut, *milieu, fin = plis # utile pour isoler les extrémités tout = [*liste_a, *liste_b] # concaténation lisible
**kwargs. Une signature def f(**kwargs) n'apprend rien au lecteur, désactive l'autocomplétion de l'éditeur, et rend le vérificateur de types (section 5) aveugle : une faute de frappe dans un nom de paramètre ne sera plus signalée nulle part — elle atterrira silencieusement dans le dictionnaire. Réservez la notation aux cas où l'on transmet réellement à autre chose. Pour vos propres fonctions, des paramètres nommés explicitement valent toujours mieux.
Le mécanisme, en trois temps
Premier point à admettre : en Python, une fonction est une valeur comme une autre. On peut la mettre dans une variable, la passer en argument, la renvoyer.
def bonjour(nom):
return f"Bonjour {nom}"
f = bonjour # pas d'appel : on manipule la fonction elle-même
print(f("Nils")) # Bonjour Nils
print(bonjour.__name__) # bonjour → une fonction a des attributsDeuxième point : on peut donc écrire une fonction qui prend une fonction et en renvoie une nouvelle.
import time
def chrono(fonction): # reçoit la fonction à emballer
def emballage(*args, **kwargs): # la nouvelle fonction qui la remplacera
debut = time.perf_counter()
resultat = fonction(*args, **kwargs) # on appelle l'originale
duree = time.perf_counter() - debut
print(f"{fonction.__name__} : {duree:.3f} s")
return resultat # on renvoie SON résultat, inchangé
return emballage # on renvoie l'emballage, pas son appel
def entrainer(n):
time.sleep(n)
return "modèle"
entrainer = chrono(entrainer) # on remplace la fonction par sa version emballée
entrainer(0.5) # entrainer : 0.500 sOn retrouve ici *args et **kwargs dans leur rôle le plus caractéristique : l'emballage accepte n'importe quelle signature et la retransmet intacte. Sans eux, le décorateur ne fonctionnerait que pour des fonctions à un seul argument.
Troisième point : la ligne entrainer = chrono(entrainer) est si fréquente que Python lui donne une notation dédiée, le @. Les deux écritures ci-dessous sont rigoureusement équivalentes :
# AVANT : réécriture explicite def entrainer(n): ... entrainer = chrono(entrainer) # APRÈS : la même chose, en une ligne au-dessus de la définition @chrono def entrainer(n): ...
Le détail qui manque : functools.wraps
Tel qu'écrit plus haut, le décorateur remplace la fonction par emballage : entrainer.__name__ vaut désormais "emballage", la docstring a disparu, et les outils de débogage affichent le mauvais nom. @functools.wraps(fonction), posé sur l'emballage, recopie ces métadonnées. Ce n'est pas optionnel — sans lui, les messages d'erreur deviennent trompeurs.
import functools
def chrono(fonction):
@functools.wraps(fonction) # conserve nom, docstring, annotations
def emballage(*args, **kwargs):
...
return emballageLes décorateurs de la bibliothèque standard
On en écrit rarement ; on en utilise constamment. Voici ceux qu'il faut reconnaître :
class Jeu:
def __init__(self, X, y):
self.X, self.y = X, y
@property
def taille(self): # se lit comme un attribut, se calcule à la demande
return len(self.y)
@property
def taux_positifs(self):
return float(self.y.mean())
jeu = Jeu(X, y)
print(jeu.taille) # PAS jeu.taille() : le @property fait l'appel pour nous
print(jeu.taux_positifs)L'intérêt : on expose une valeur calculée avec la même syntaxe qu'un attribut stocké. On peut donc transformer un attribut simple en valeur calculée plus tard, sans casser le code qui l'utilise.
Par défaut, une propriété est en lecture seule : écrire jeu.taille = 1000 lève une erreur. C'est souvent le comportement souhaité — la taille découle des données, la fixer directement n'aurait pas de sens. Quand on veut au contraire autoriser l'écriture, on ajoute un second bloc décoré par @taille.setter, et c'est lui qui devient l'endroit naturel où poser la validation.
class Jeu:
def __init__(self, X, y):
self.X, self.y = X, y
@property
def taille(self): # le LECTEUR : jeu.taille
return len(self.y)
@taille.setter # l'ÉCRIVAIN : jeu.taille = ...
def taille(self, n): # même nom, c'est obligatoire
if not isinstance(n, int):
raise TypeError(f"taille doit être un entier, reçu {type(n).__name__}")
if not 0 < n <= len(self.y):
raise ValueError(f"taille attendue entre 1 et {len(self.y)}, reçu {n}")
self.X, self.y = self.X[:n], self.y[:n] # on tronque le jeu
jeu = Jeu(X, y)
print(jeu.taille) # 50000
jeu.taille = 1000 # appelle le setter → le jeu est tronqué
print(jeu.taille) # 1000
jeu.taille = 5000 # ValueError: taille attendue entre 1 et 1000, reçu 5000
jeu.taille = 12.5 # TypeError: taille doit être un entier, reçu floatTrois points à retenir. D'abord, le nom doit être identique : @taille.setter se lit « le setter de la propriété taille définie juste au-dessus », et les deux méthodes s'appellent taille. Ensuite, aucun code appelant ne change : jeu.taille = 1000 ressemble à une affectation ordinaire, alors qu'une méthode complète s'exécute — c'est tout l'intérêt, on peut ajouter la validation après coup sans toucher au reste du programme. Enfin, sans setter, la même ligne produit un message parfaitement clair :
jeu.taille = 1000 # AttributeError: property 'taille' of 'Jeu' object has no setter
Le même trio existe pour la suppression, avec @taille.deleter, appelé par del jeu.taille — nettement plus rare.
@staticmethod — une fonction rangée dans la classe
Le problème : une fonction utilitaire appartient logiquement à une classe — elle traite ses données — mais n'a besoin ni de l'instance, ni de la classe. La mettre en dehors éparpille le code ; en faire une méthode normale oblige à écrire un self dont on ne se sert pas.
class Jeu:
def __init__(self, X, y):
self.X, self.y = X, y
@staticmethod # pas de self : c'est une fonction normale
def normaliser(x): # qui vit simplement dans la classe
return (x - x.mean()) / x.std()
# appelable des deux côtés, sans avoir besoin d'un objet
Jeu.normaliser(colonne) # sur la classe
jeu.normaliser(colonne) # sur une instance : même résultatLe gain est l'organisation et la lisibilité : quiconque lit Jeu.normaliser sait immédiatement à quel domaine la fonction appartient. Le signal envoyé compte autant que la technique — @staticmethod dit « cette fonction ne touche à l'état d'aucun objet », ce qui la rend triviale à tester.
@classmethod — un constructeur alternatif
Le problème : une classe n'a qu'un __init__, mais on veut souvent la construire de plusieurs façons — depuis un fichier, depuis un dictionnaire, depuis une base. Entasser tous ces cas dans __init__ avec des arguments mutuellement exclusifs donne un constructeur illisible.
import json
from pathlib import Path
class Config:
def __init__(self, n_estimators, learning_rate):
self.n_estimators = n_estimators
self.learning_rate = learning_rate
@classmethod # reçoit la CLASSE (cls), pas l'instance
def depuis_json(cls, chemin):
donnees = json.loads(Path(chemin).read_text())
return cls(**donnees) # cls(...) construit l'objet
@classmethod
def par_defaut(cls):
return cls(n_estimators=300, learning_rate=0.05)
cfg = Config.depuis_json("params.json") # se lit comme une intention
cfg = Config.par_defaut()Pourquoi cls(...) plutôt que Config(...) en dur ? Parce qu'une sous-classe hérite de la méthode : ConfigGPU.depuis_json(...) construira alors une ConfigGPU, et non une Config. C'est exactement ce que font datetime.now(), Path.cwd() ou pd.DataFrame.from_dict() — tous des méthodes de classe.
@functools.cached_property — calculé une fois, puis retenu
Le problème : une @property recalcule à chaque lecture. Sur une valeur coûteuse — une statistique sur un million de lignes —, une boucle qui la lit mille fois la recalcule mille fois.
import functools
class Jeu:
def __init__(self, X, y):
self.X, self.y = X, y
@property
def moyenne(self): # recalculée à CHAQUE accès
print("calcul…")
return self.X.mean(axis=0)
@functools.cached_property
def correlations(self): # calculée UNE fois, puis mémorisée
print("calcul lourd…")
return np.corrcoef(self.X, rowvar=False)
jeu = Jeu(X, y)
jeu.moyenne; jeu.moyenne # « calcul… » deux fois
jeu.correlations; jeu.correlations # « calcul lourd… » une seule fois
del jeu.correlations # pour forcer un recalcul : on supprime le cacheSous le capot, la valeur est simplement rangée dans l'objet lui-même. Deux conséquences : le cache disparaît avec l'objet — pas de fuite mémoire globale, contrairement à @cache —, mais il devient faux si l'on modifie les données après coup. La règle est donc : cached_property sur des objets qu'on ne modifie plus après construction, ce qui rejoint l'argument des dataclasses figées de la section suivante.
@functools.cache et @lru_cache — mémoriser les appels
Le problème : une fonction pure — même entrée, même sortie — appelée plusieurs fois avec les mêmes arguments refait le même travail. Charger un référentiel depuis le disque à chaque appel dans une boucle est le cas d'école.
import functools
import pandas as pd
@functools.cache # cache sans limite de taille
def charger_referentiel(annee: int) -> pd.DataFrame:
print(f"lecture disque {annee}")
return pd.read_parquet(f"ref_{annee}.parquet")
charger_referentiel(2025) # lecture disque 2025
charger_referentiel(2025) # rien : servi depuis le cache
@functools.lru_cache(maxsize=1024) # idem, mais borné : au-delà de 1024
def distance(a: str, b: str) -> float: # entrées, la moins récemment utilisée
... # est évincée (Least Recently Used)
distance.cache_info() # CacheInfo(hits=812, misses=1024, maxsize=1024, currsize=1024)
charger_referentiel.cache_clear() # vider explicitementDeux contraintes, et une mise en garde. Les arguments doivent être hachables : un entier ou une chaîne conviennent, une liste ou un DataFrame non — d'où l'intérêt des configurations immuables. Et le cache vit pour toute la durée du programme : sur une fonction appelée avec des millions d'arguments distincts, @cache est une fuite de mémoire pure et simple, alors que @lru_cache(maxsize=…) plafonne la consommation. En cas de doute, préférez la version bornée.
@contextlib.contextmanager — fabriquer un bloc with
Le problème : certaines actions vont par paires — ouvrir/fermer, démarrer un chronomètre/l'arrêter, changer un réglage/le remettre. Écrire la seconde moitié à la main s'oublie, surtout quand une exception coupe l'exécution au milieu.
import time
from contextlib import contextmanager
@contextmanager
def chronometre(nom):
debut = time.perf_counter()
try:
yield # ← ici s'exécute le corps du « with »
finally: # exécuté MÊME si une exception survient
print(f"{nom} : {time.perf_counter() - debut:.2f} s")
with chronometre("entraînement"):
modele.fit(X, y)
# entraînement : 12.43 sTout ce qui précède le yield s'exécute à l'entrée du bloc, tout ce qui suit à la sortie — et le finally garantit que la sortie a lieu même en cas d'erreur. C'est la version courte de ce qu'on écrirait sinon avec une classe et deux méthodes spéciales, __enter__ et __exit__. On croise le résultat partout : with open(...), with torch.no_grad():, with mlflow.start_run():.
@functools.singledispatch — une fonction, plusieurs types
Le problème : une fonction doit se comporter différemment selon le type qu'elle reçoit. La version naïve est une cascade de if isinstance(...) qui s'allonge à chaque nouveau cas, et qu'il faut modifier pour l'étendre.
from functools import singledispatch
import numpy as np, pandas as pd
@singledispatch # cas par défaut
def resumer(donnees):
raise TypeError(f"type non pris en charge : {type(donnees).__name__}")
@resumer.register # le type est lu dans l'annotation
def _(donnees: pd.DataFrame):
return f"DataFrame {donnees.shape[0]}×{donnees.shape[1]}"
@resumer.register
def _(donnees: np.ndarray):
return f"tableau {donnees.shape}, moyenne {donnees.mean():.3f}"
@resumer.register
def _(donnees: list):
return f"liste de {len(donnees)} éléments"
resumer(df) # DataFrame 1000×12
resumer(np.zeros((3, 4))) # tableau (3, 4), moyenne 0.000
resumer("abc") # TypeError: type non pris en charge : strL'avantage sur la cascade de if : on ajoute un type en écrivant une nouvelle fonction, sans toucher aux précédentes. La répartition se fait sur le type du premier argument seulement — d'où le nom. C'est utile pour une fonction de conversion ou d'affichage exposée à des entrées variées ; au-delà, une hiérarchie de classes est souvent plus claire.
Le même mécanisme se retrouve partout dans l'écosystème : @pytest.fixture et @pytest.mark.parametrize (section 7), @app.get("/items") dans FastAPI (module 6), @task dans les orchestrateurs, @numba.njit pour compiler une boucle, @abstractmethod pour déclarer une méthode qu'une sous-classe doit obligatoirement fournir. Savoir lire un @ comme « cette fonction est emballée par ceci » suffit à comprendre ces bibliothèques.
4. Dataclasses : frozen, slots et le reste
Le problème, en partant du début
Commençons par le besoin. Un entraînement dépend de plusieurs réglages : un chemin de données, un nombre d'arbres, un taux d'apprentissage, une graine aléatoire. On pourrait les trimballer en quatre variables séparées ; on préfère les regrouper dans un seul objet, qu'on passe en un seul argument et qu'on peut sauvegarder tel quel. Écrivons donc la classe la plus simple possible :
class TrainConfig:
def __init__(self, data_path, n_estimators=300, learning_rate=0.05):
self.data_path = data_path
self.n_estimators = n_estimators
self.learning_rate = learning_rate
cfg = TrainConfig("data/train.parquet")
# 1) je veux VOIR ce que contient cfg
print(cfg)
# <__main__.TrainConfig object at 0x7f3a2c1d5e50> ← inutilisable
# 2) je veux comparer deux configurations identiques
a = TrainConfig("data/train.parquet")
b = TrainConfig("data/train.parquet")
print(a == b)
# False ← alors qu'elles sont IDENTIQUES
# 3) je veux dédoublonner une liste de configurations
len({a, b})
# 2 ← deux fois la même, comptée deux foisLes trois comportements sont décevants, et pour la même raison. Python ne devine rien : quand on lui demande d'afficher un objet ou d'en comparer deux, il pose la question à l'objet lui-même. Si la classe ne répond pas, Python se rabat sur des réponses par défaut qui sont correctes mais sans intérêt : afficher l'adresse mémoire, et considérer que deux objets ne sont égaux que s'ils sont le même objet — pas s'ils contiennent la même chose.
Comment Python « pose la question » : les méthodes spéciales
Le mécanisme est systématique et vaut la peine d'être compris une fois, parce qu'il explique une grande partie du langage. Certaines méthodes ont un nom entouré de deux tirets bas — __init__, __repr__, __eq__ — et on ne les appelle jamais soi-même : c'est Python qui les appelle pour nous, dans des situations précises. On les appelle « méthodes spéciales », ou dunder dans le jargon, pour double underscore.
L'image la plus juste est celle d'un formulaire à remplir. Le langage pose des questions standard ; chaque classe choisit d'y répondre ou non, et Python utilise la réponse partout où elle s'applique :
VOTRE CODE PYTHON APPELLE SI LA CLASSE NE RÉPOND PAS
──────────────────────────────────────────────────────────────────────────
TrainConfig(...) __init__(self, ...) objet vide, sans attribut
print(cfg) / repr(cfg) __repr__(self) <...object at 0x7f3a…>
a == b __eq__(self, autre) comparaison d'IDENTITÉ
(est-ce le même objet ?)
{a, b} / dict[cfg] __hash__(self) fondé sur l'adresse mémoire
a < b, sorted([...]) __lt__(self, autre) TypeError : non ordonnable
len(jeu) __len__(self) TypeError : pas de longueur
for x in jeu __iter__(self) TypeError : non itérable
jeu[3] __getitem__(self, i) TypeError : non indexable
Aucune n'est obligatoire. Une classe est simplement plus ou moins
« équipée » selon celles qu'elle définit.Deux d'entre elles nous intéressent tout de suite.
__repr__ — « à quoi ressembles-tu, écrit ? »
Appelée par print(), par repr(), par l'affichage automatique d'une variable dans un notebook, et — c'est le point qui compte — par le débogueur et par les messages d'erreur. La convention est que le texte renvoyé soit sans ambiguïté, idéalement recopiable pour reconstruire l'objet. Sans __repr__, déboguer revient à lire des adresses mémoire ; avec, on voit la configuration.
def __repr__(self):
return f"TrainConfig(data_path={self.data_path!r}, n_estimators={self.n_estimators})"
# !r applique repr() à la valeur : une chaîne apparaît avec ses guillemets,
# ce qui évite de confondre le nombre 300 et la chaîne "300"
print(cfg) # TrainConfig(data_path='data/train.parquet', n_estimators=300)__eq__ — « es-tu égal à celui-ci ? »
Appelée par ==, et donc aussi par in sur une liste, par assert a == b dans les tests, par list.count()… Par défaut, Python répond « seulement si c'est physiquement le même objet en mémoire ». C'est presque toujours l'inverse de ce qu'on veut : deux configurations qui contiennent les mêmes valeurs sont la même configuration.
def __eq__(self, autre):
if not isinstance(autre, TrainConfig):
return NotImplemented # « je ne sais pas comparer » : Python
# essaiera l'autre sens, puis dira False
return (self.data_path, self.n_estimators) == (autre.data_path, autre.n_estimators)
# on compare deux TUPLES : Python compare élément par élément, dans l'ordre
a == b # True, maintenantUne subtilité qui surprend : définir __eq__ désactive automatiquement __hash__, donc l'objet ne peut plus servir de clé de dictionnaire ni entrer dans un set. La raison est logique — deux objets égaux doivent avoir la même empreinte, et Python refuse d'en fabriquer une qui serait fausse. On la redéfinit explicitement, ou l'on rend l'objet immuable, auquel cas @dataclass(frozen=True) s'en charge.
D'où vient @dataclass
Écrire ces méthodes n'est pas difficile ; c'est mécanique. Le constructeur recopie chaque argument dans un attribut du même nom, l'affichage énumère les mêmes champs, la comparaison compare les mêmes champs. Trois fois la même liste de noms, recopiée à la main — et donc trois occasions d'oublier de mettre à jour l'une des trois le jour où l'on ajoute un paramètre. C'est exactement ce qu'on veut confier à une machine.
@dataclass est cette machine. C'est un décorateur ordinaire, au sens de la section 3 : il reçoit la classe, lit la liste de ses champs, écrit les méthodes spéciales à votre place, et renvoie la classe complétée. Rien de magique — ce qu'il produit est exactement le code qu'on aurait tapé, et l'on peut d'ailleurs le lire avec inspect.getsource ou en affichant TrainConfig.__init__.
Comparons donc les deux versions.
class TrainConfig:
def __init__(self, data_path, n_estimators=300, learning_rate=0.05, random_state=42):
self.data_path = data_path
self.n_estimators = n_estimators
self.learning_rate = learning_rate
self.random_state = random_state
def __repr__(self):
return (f"TrainConfig(data_path={self.data_path!r}, "
f"n_estimators={self.n_estimators}, "
f"learning_rate={self.learning_rate}, "
f"random_state={self.random_state})")
def __eq__(self, autre):
if not isinstance(autre, TrainConfig):
return NotImplemented
return (self.data_path, self.n_estimators, self.learning_rate, self.random_state) == \
(autre.data_path, autre.n_estimators, autre.learning_rate, autre.random_state)from dataclasses import dataclass, field
from pathlib import Path
@dataclass
class TrainConfig:
data_path: Path # pas de valeur par défaut : argument obligatoire
n_estimators: int = 300
learning_rate: float = 0.05
random_state: int | None = 42
cfg = TrainConfig(Path("data/train.parquet"), learning_rate=0.1)
print(cfg) # TrainConfig(data_path=PosixPath('data/train.parquet'),
# n_estimators=300, learning_rate=0.1, random_state=42)
cfg == TrainConfig(Path("data/train.parquet"), learning_rate=0.1) # TrueQuatre lignes déclaratives remplacent une vingtaine de lignes mécaniques, et l'on n'énumère plus les champs qu'une seule fois. Ajouter un paramètre demande désormais d'ajouter une ligne, contre trois endroits à modifier auparavant.
Une remarque importante au passage : ici, les annotations de type ne sont pas décoratives. C'est leur présence qui déclenche la génération du champ — une ligne n_estimators = 300 sans annotation serait ignorée par le décorateur. C'est l'un des rares endroits où le typage a un effet réel à l'exécution (voir la section 5).
Ce que cela change concrètement
- Déboguer. Un
print(cfg)ou un point d'arrêt montre les valeurs, pas une adresse mémoire. C'est le bénéfice quotidien, et de loin le plus sous-estimé. - Tester.
assert cfg == attendudevient possible, et pytest affiche un diff champ par champ quand l'assertion échoue — au lieu d'un simple « False ». - Journaliser.
asdict(cfg)donne un dictionnaire prêt pourjson.dumpsou pour l'enregistrement des paramètres d'une expérience (module 26). Une configuration lisible dans les journaux est ce qui permet de reproduire un entraînement six mois plus tard. - Dédoublonner et mettre en cache. Avec
frozen=True, l'objet devient hachable : il peut servir de clé de dictionnaire, entrer dans unset, ou être l'argument d'une fonction décorée par@cache(section 3). - Se tromper moins. Toutes les méthodes générées restent cohérentes entre elles par construction, ce qu'une recopie manuelle ne garantit jamais dans la durée.
Reste à choisir ce que le décorateur doit générer exactement : c'est le rôle de ses options.
Les options utiles
@dataclass(frozen=True, slots=True)
class TrainConfig:
data_path: Path
n_estimators: int = 300
learning_rate: float = 0.05
features: tuple[str, ...] = () # tuple, pas list : voir plus basfrozen=True — l'objet devient immuable. Toute tentative de modification lève une erreur au lieu de passer inaperçue :
cfg.learning_rate = 0.5 # FrozenInstanceError: cannot assign to field 'learning_rate'
Pourquoi vouloir cela ? Parce qu'une configuration qui change en cours d'exécution est une source de bugs irreproductibles : on lance un entraînement, une fonction modifie discrètement un paramètre au passage, et le résultat ne correspond plus à ce qu'on croit avoir demandé. Une configuration figée est une garantie que ce qu'on a lu au début est ce qui a servi jusqu'au bout. Bénéfice secondaire : un objet immuable est hachable, donc utilisable comme clé de dictionnaire ou argument d'une fonction mise en cache (section 3). Pour obtenir une variante, on ne modifie pas : on copie en changeant ce qu'il faut, avec dataclasses.replace(cfg, learning_rate=0.1).
slots=True — attributs fixés d'avance. Par défaut, chaque objet Python porte un dictionnaire interne (__dict__) qui associe des noms d'attributs à des valeurs, ce qui permet d'en ajouter n'importe quand. C'est souple, mais cela coûte de la mémoire et laisse passer les fautes de frappe :
# sans slots : la faute de frappe crée un NOUVEL attribut, en silence cfg.learnig_rate = 0.1 # aucune erreur ; le vrai paramètre n'a pas bougé # avec slots=True : la liste des attributs est figée à la création de la classe cfg.learnig_rate = 0.1 # AttributeError: 'TrainConfig' object has no attribute 'learnig_rate'
L'économie de mémoire (souvent 30 à 40 % par instance) ne compte que si l'on crée des millions d'objets ; la détection des fautes de frappe, elle, sert tous les jours.
features: list[str] = [] lève une erreur, et c'est une bonne nouvelle : la liste serait créée une seule fois et partagée par toutes les instances — un classique des bugs Python. Deux solutions : un type immuable (tuple[str, ...] = ()), ou field(default_factory=list), qui construit une liste neuve à chaque instance.
from dataclasses import dataclass, field, asdict, replace
@dataclass(frozen=True, slots=True, order=True, kw_only=True)
class Resultat:
rmse: float
modele: str
# exclu de la comparaison et de l'affichage (par exemple un gros objet) :
predictions: list = field(default_factory=list, compare=False, repr=False)
def __post_init__(self): # appelé après l'init généré : validation
if self.rmse < 0:
raise ValueError("un RMSE ne peut pas être négatif")
r = Resultat(rmse=0.42, modele="gbm") # kw_only : arguments NOMMÉS obligatoires
sorted([r1, r2, r3]) # order=True : comparaison champ par champ
asdict(r) # → dict, prêt pour json.dumps ou MLflow
replace(r, modele="xgb") # copie modifiée (l'original est figé)Deux cousines à connaître : typing.NamedTuple, plus légère mais limitée à un tuple immuable, et pydantic, qui ressemble beaucoup à une dataclass mais vérifie et convertit les types à l'exécution. La règle de choix : dataclass pour les objets internes au code, pydantic dès que les données viennent de l'extérieur — fichier de configuration, requête HTTP, réponse d'API. C'est le socle de FastAPI, détaillé au module 6.
5. Les annotations de type
Ce que c'est, et ce que ce n'est pas
Une annotation comme def rmse(y: np.ndarray) -> float est une métadonnée. Python ne la vérifie pas à l'exécution : passer une chaîne de caractères à cette fonction ne déclenchera aucune erreur de typage (seulement, plus loin, une erreur incompréhensible). Les annotations servent à trois choses : un vérificateur statique (mypy, pyright) qui lit le code sans l'exécuter et signale les incohérences, l'éditeur qui propose l'autocomplétion et détecte les fautes, et le lecteur, pour qui la signature devient une documentation qui ne peut pas mentir trop longtemps.
from collections.abc import Iterable, Callable, Sequence
from pathlib import Path
import numpy as np
from numpy.typing import NDArray
def charger(chemin: Path) -> pd.DataFrame: ...
def normaliser(x: NDArray[np.float64]) -> NDArray[np.float64]: ...
def selectionner(noms: Sequence[str], seuil: float = 0.5) -> list[str]: ...
# "peut valoir None" : le | remplace l'ancien Optional[...]
def charger_modele(chemin: Path | None = None) -> Modele | None: ...
# une fonction en argument
def appliquer(f: Callable[[float], float], valeurs: Iterable[float]) -> list[float]:
return [f(v) for v in valeurs]
# un dictionnaire dont on connaît les clés
scores: dict[str, float] = {"rmse": 0.42, "mae": 0.31}Où le typage rapporte, et où il coûte
Le rendement est maximal aux frontières : signatures des fonctions publiques, objets de configuration, structures de données échangées entre modules. Il est faible à l'intérieur d'un calcul numérique, où annoter chaque variable intermédiaire alourdit sans rien apporter. Et il faut connaître sa limite principale : NDArray[np.float64] ne dit rien de la forme du tableau, qui est pourtant la première source d'erreurs en ML. D'où l'habitude de documenter la forme attendue en commentaire — (n_samples, n_features) — et de la vérifier par un assert X.ndim == 2 en début de fonction.
6. Itérateurs et générateurs
Le problème
Un fichier de journaux de 40 Go, une machine avec 16 Go de RAM. La ligne lignes = open("events.jsonl").readlines() échoue par manque de mémoire, parce qu'elle construit la liste complète avant d'en traiter le premier élément. Il faut une façon de produire les données au fur et à mesure : un distributeur qui fabrique à la demande, plutôt qu'un entrepôt qu'on remplit d'abord.
Itérable et itérateur : deux notions distinctes
Un itérable est un objet sur lequel on peut faire une boucle for (une liste, un dictionnaire, un fichier). Un itérateur est l'objet qui garde la position courante et sait donner l'élément suivant. La boucle for cache ce mécanisme : elle demande d'abord un itérateur à l'itérable, puis lui réclame les éléments un par un jusqu'à épuisement.
nombres = [10, 20, 30]
it = iter(nombres) # on demande un itérateur à l'itérable
print(next(it)) # 10
print(next(it)) # 20
print(next(it)) # 30
print(next(it)) # StopIteration : c'est le signal de fin, que for intercepte
# la boucle ci-dessous est exactement équivalente à ce qui précède
for n in nombres:
print(n)Écrire son propre itérateur (la version longue)
Pour comprendre ce que les générateurs automatisent, écrivons d'abord un itérateur « à la main ». Il suffit d'implémenter deux méthodes : __iter__, qui renvoie l'itérateur lui-même, et __next__, qui renvoie l'élément suivant ou lève StopIteration.
class Lots:
"""Découpe une séquence en lots de taille fixe."""
def __init__(self, donnees, taille):
self.donnees = donnees
self.taille = taille
self.position = 0 # l'ÉTAT que l'itérateur doit mémoriser
def __iter__(self):
return self # l'objet est son propre itérateur
def __next__(self):
if self.position >= len(self.donnees):
raise StopIteration # plus rien à donner : fin de la boucle
lot = self.donnees[self.position : self.position + self.taille]
self.position += self.taille
return lot
for lot in Lots(list(range(10)), 3):
print(lot) # [0,1,2] puis [3,4,5] puis [6,7,8] puis [9]Le générateur : le même résultat en quatre lignes
Une fonction qui contient le mot-clé yield ne se comporte plus comme une fonction ordinaire. L'appeler n'exécute rien : cela renvoie un générateur. À chaque next(), le corps s'exécute jusqu'au prochain yield, renvoie la valeur, puis se met en pause en conservant tout son état local — variables et position dans le code. L'appel suivant reprend exactement là où on s'était arrêté.
def lots(donnees, taille):
for debut in range(0, len(donnees), taille):
yield donnees[debut : debut + taille] # pause ici, reprise au prochain appel
for lot in lots(list(range(10)), 3):
print(lot) # résultat identiqueLe générateur écrit pour nous __iter__, __next__, la gestion de l'état et le StopIteration final. Observons la mise en pause pas à pas :
def trace():
print(" → début du corps")
yield 1
print(" → reprise après le premier yield")
yield 2
print(" → reprise après le second yield")
g = trace() # RIEN ne s'affiche : le corps n'a pas encore démarré
print(next(g)) # → début du corps puis affiche 1
print(next(g)) # → reprise après ... puis affiche 2
next(g) # → reprise après ... puis StopIterationApplication : traiter un fichier plus gros que la mémoire
import json, itertools
from collections.abc import Iterator
def lire_lignes(chemin: str) -> Iterator[dict]:
"""Une ligne en mémoire à la fois, quelle que soit la taille du fichier."""
with open(chemin, encoding="utf-8") as f:
for ligne in f: # un fichier ouvert EST déjà un itérateur
yield json.loads(ligne)
def valides(evenements: Iterator[dict]) -> Iterator[dict]:
"""Filtre, sans rien matérialiser."""
for e in evenements:
if e.get("prix") and e["prix"] > 0:
yield e
def par_lots(iterable, taille: int):
"""Regroupe en lots — indispensable pour l'inférence batchée (module 6)."""
it = iter(iterable)
while lot := list(itertools.islice(it, taille)): # := affecte ET teste
yield lot
# les trois générateurs se chaînent : la mémoire reste en O(taille du lot)
flux = par_lots(valides(lire_lignes("events.jsonl")), 1024)
for lot in flux:
scores = modele.predict(lot)Rien n'est exécuté tant qu'on ne consomme pas le flux : c'est l'évaluation paresseuse. Chaque lot traverse les trois étapes à la demande, et la mémoire ne dépend que de la taille du lot. En Python 3.12+, itertools.batched fait le travail de par_lots nativement.
# expression génératrice : comme une compréhension de liste, mais paresseuse
carres = (x ** 2 for x in range(10_000_000)) # rien n'est calculé ni stocké
total = sum(carres) # calcul au fil de la consommation
# yield from : déléguer à un autre générateur (aplatissement, composition)
def tous_les_fichiers(dossiers):
for d in dossiers:
yield from lire_lignes(d)
# générateur infini : parfaitement légitime, tant qu'on le borne à la consommation
def compteur(debut=0):
n = debut
while True:
yield n
n += 1
premiers = list(itertools.islice(compteur(), 5)) # [0, 1, 2, 3, 4]
# itertools : groupby, chain, product, accumulate, tee — tous paresseux
for cle, groupe in itertools.groupby(sorted(lignes, key=cle_ville), key=cle_ville):
print(cle, sum(1 for _ in groupe))len() et l'indexation n'existent pas sur un générateur, puisqu'on ignore combien d'éléments restent. Si l'on a besoin de plusieurs passages, il faut soit matérialiser avec list(...) — en acceptant le coût mémoire —, soit relancer le générateur depuis sa source.
7. Qualité : ruff, pytest, pre-commit
ruff : formatage et analyse statique
ruff remplace flake8, isort, black et une bonne partie de pylint, en une fraction du temps. Deux usages complémentaires : check signale les erreurs probables (import inutilisé, variable jamais lue, comparaison suspecte) et format réécrit le code dans un style unique.
uv run ruff check --fix . # signale et corrige ce qui peut l'être uv run ruff format . # formatage automatique uv run mypy src/ # vérification des types
Le formatage automatique n'est pas une coquetterie : il supprime les différences de style dans les diffs, qui polluent les revues et masquent les vrais changements.
pytest : tester du code de data science
La question n'est pas « comment écrire un test » mais « que faut-il tester ». On ne teste pas « le modèle atteint 92 % d'accuracy » — cela dépend des données et relève du suivi en production (module 26). On teste la plomberie : les formes, les cas dégénérés, le déterminisme, les invariants métier.
import numpy as np
import pytest
from mon_projet.features import ajouter_features_temporelles, entrainer
@pytest.fixture # une donnée de test réutilisable
def df_exemple():
return pd.DataFrame({"date": pd.date_range("2025-01-01", periods=30),
"valeur": np.arange(30, dtype=float)})
def test_pas_de_fuite_temporelle(df_exemple):
"""Une feature calculée à t ne doit jamais utiliser d'information de t+1."""
out = ajouter_features_temporelles(df_exemple)
assert out["moyenne_mobile_7j"].iloc[0] != out["valeur"].iloc[0]
@pytest.mark.parametrize("n_lignes", [0, 1, 1000]) # le test est joué 3 fois
def test_robustesse_aux_tailles(n_lignes):
"""Les cas dégénérés — vide, une seule ligne — cassent souvent."""
df = generer_df(n_lignes)
assert len(ajouter_features_temporelles(df)) == n_lignes
def test_deterministe():
"""Même graine, même résultat : sinon rien n'est reproductible."""
np.testing.assert_allclose(entrainer(seed=0).predict(X), entrainer(seed=0).predict(X))
def test_probabilites_valides():
p = modele.predict_proba(X)
assert np.all((p >= 0) & (p <= 1)) and np.allclose(p.sum(axis=1), 1.0)pre-commit : automatiser les vérifications
Le problème. Les outils précédents ne servent que si on pense à les lancer. En pratique, on oublie, et le code mal formaté ou cassé entre dans l'historique Git ; la chaîne d'intégration continue le signale dix minutes plus tard, et il faut un commit supplémentaire pour corriger.
La solution. Git sait déclencher des scripts à certains moments de son cycle — ce sont les hooks. pre-commit est un outil qui installe un hook déclenché avant chaque commit, lance les vérifications sur les seuls fichiers modifiés, et annule le commit si quelque chose ne va pas. Le code fautif n'entre jamais dans l'historique.
uv add --dev pre-commit uv run pre-commit install # écrit le hook dans .git/hooks/pre-commit # vérifier tout le dépôt (utile la première fois, ou en CI) uv run pre-commit run --all-files # mettre à jour les versions des outils déclarés uv run pre-commit autoupdate
repos:
# ── hooks génériques, fournis par pre-commit lui-même ──
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace # supprime les espaces en fin de ligne
- id: end-of-file-fixer # garantit un saut de ligne final
- id: check-yaml # valide la syntaxe des fichiers YAML
- id: check-added-large-files # REFUSE les fichiers volumineux
args: ["--maxkb=5000"] # → empêche de committer un CSV par erreur
- id: check-merge-conflict # détecte les marqueurs de conflit oubliés
- id: detect-private-key # refuse une clé privée committée
# ── lint et formatage ──
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.9
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
# ── types ──
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.11.2
hooks:
- id: mypy
additional_dependencies: [numpy, pandas-stubs]
# ── notebooks : retire les sorties avant commit ──
- repo: https://github.com/kynan/nbstripout
rev: 0.7.1
hooks:
- id: nbstripoutDeux hooks méritent qu'on s'y arrête. check-added-large-files est la meilleure protection contre l'erreur la plus coûteuse d'un projet data — committer un jeu de données (section 8). nbstripout retire les sorties des notebooks : sans lui, chaque exécution produit un diff de plusieurs milliers de lignes illisibles, et les images encodées font gonfler le dépôt indéfiniment.
Un dernier détail pratique : git commit --no-verify contourne les hooks. C'est utile dans une urgence, et c'est aussi la raison pour laquelle les mêmes vérifications doivent tourner dans la chaîne d'intégration continue, où personne ne peut les contourner (module 26).
8. Git pour un projet data
Le workflow que j'utilise, volontairement minimal : main toujours fonctionnelle, une branche par sujet, une pull request même en solo — elle force une relecture du diff complet, ce qui attrape beaucoup d'erreurs.
git switch -c feat/gradient-boosting # créer et basculer sur une branche git add -p # relire chaque morceau avant de l'indexer git commit -m "feat(model): baseline XGBoost, RMSE 0.42 en CV" git fetch origin git rebase origin/main # historique linéaire AVANT publication git push -u origin feat/gradient-boosting git log --oneline --graph -15 # visualiser l'historique git diff main...HEAD # tout ce que la branche apporte
Rebase ou merge ? Rebase tant que la branche est privée : l'historique reste linéaire et lisible. Merge dès qu'elle est partagée : réécrire des commits que quelqu'un a déjà récupérés casse son dépôt. La règle tient en une phrase — ne jamais réécrire un historique déjà publié.
Les messages suivent la convention Conventional Commits (feat:, fix:, refactor:, docs:) : cela permet de générer un changelog et, surtout, de retrouver pourquoi une transformation a été ajoutée six mois plus tard.
data/, *.csv, *.parquet, *.pkl, models/, .venv/ et .ipynb_checkpoints/ dans .gitignore dès le premier commit. Pour versionner données et modèles, voir DVC au module 26.
Ce qu'il faut retenir du module
- Le code réutilisable vit dans
src/, jamais dans un notebook ;pyproject.tomlcentralise tout. - UV donne un environnement reproductible en une commande : contraintes dans
pyproject.toml, solution exacte dansuv.lock, les deux versionnés. - Le
[build-system]dit comment fabriquer le paquet : hatchling par défaut, setuptools pour l'existant et le C, maturin pour le Rust. *argsramasse les arguments positionnels en tuple,**kwargsles nommés en dictionnaire ; à l'appel, les mêmes étoiles déballent.- Un décorateur est une fonction qui en emballe une autre ;
@est un raccourci pourf = decorateur(f). - Les méthodes à doubles tirets bas sont appelées par Python :
__repr__pour afficher,__eq__pour comparer. Sans elles, on lit des adresses mémoire et deux objets identiques sont différents. @dataclassécrit ces méthodes à partir des annotations ;frozen=Trueprotège des mutations silencieuses,slots=Truedes fautes de frappe.- Les annotations ne sont pas vérifiées à l'exécution : elles servent aux outils et au lecteur, surtout aux frontières.
yieldmet une fonction en pause en conservant son état : c'est ce qui permet de traiter des données plus grosses que la mémoire.- pre-commit empêche le code mal formé d'entrer dans l'historique, et un jeu de données d'y entrer du tout.