LangueFR
Français
English
Retour au sommaire du cours

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.

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

arborescence type
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é.

pyproject.toml, commenté
[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 :

les deux artefacts d'un build
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 machine

Pourquoi 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 :

qui fait quoi depuis PEP 517/518
  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

choisir son backend
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)
bash — construire et vérifier
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 rien

L'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.

cycle de travail courant
# 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.

Le piège CUDA. En ML, ce qui casse la reproductibilité n'est presque jamais Python : c'est le couple pilote GPU / version de CUDA compilée dans les wheels de PyTorch. UV sait pointer un index alternatif (section [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.

Python — ce que * et ** ramassent
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.

Python — une signature complète, dans l'ordre
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.

Python — déballer une liste et un dictionnaire
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 :

Python — une surcouche qui ne présume rien
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 fonction

Deux 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
Le coût de **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.

Python — les fonctions sont des objets
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 attributs

Deuxième point : on peut donc écrire une fonction qui prend une fonction et en renvoie une nouvelle.

Python — un décorateur écrit à la main, sans sucre syntaxique
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 s

On 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 :

Python — avant / après
# 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 emballage

Les décorateurs de la bibliothèque standard

On en écrit rarement ; on en utilise constamment. Voici ceux qu'il faut reconnaître :

Python — @property : un attribut calculé
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.

Python — @taille.setter : autoriser l'écriture, en la contrôlant
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 float

Trois 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.

Python — @staticmethod
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ésultat

Le 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.

Python — @classmethod
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.

Python — @property contre @cached_property
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 cache

Sous 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.

Python — @cache et @lru_cache
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 explicitement

Deux 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.

Python — transformer une fonction en gestionnaire de contexte
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 s

Tout 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.

Python — @singledispatch
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 : str

L'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 :

Python — une classe minimale, et ce qu'elle donne
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 fois

Les 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 :

ce que vous écrivez, et ce que Python appelle en dessous
  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, maintenant

Une 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.

Python — AVANT : classe écrite à la main
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)
Python — APRÈS : le décorateur @dataclass écrit tout cela
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)   # True

Quatre 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 == attendu devient 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 pour json.dumps ou 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 un set, 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

Python — @dataclass(frozen=True, slots=True)
@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 bas

frozen=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.

Le piège des valeurs par défaut mutables. Écrire 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.
Python — les autres options, avec leur usage
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.

Python — la syntaxe utile en pratique
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.

Python — ce que fait réellement une boucle for
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.

Python — un itérateur de lots, écrit explicitement
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é.

Python — le même itérateur, en générateur
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 identique

Le générateur écrit pour nous __iter__, __next__, la gestion de l'état et le StopIteration final. Observons la mise en pause pas à pas :

Python — la mécanique du yield, étape par étape
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 StopIteration

Application : traiter un fichier plus gros que la mémoire

Python — un pipeline de générateurs
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.

Python — les compléments utiles
# 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))
Un générateur ne se parcourt qu'une fois. Après consommation, il est épuisé : une seconde boucle ne produira rien, silencieusement. De même, 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.

tests/test_features.py
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.

installation, une fois par projet
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
.pre-commit-config.yaml, commenté
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: nbstripout

Deux 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.

bash
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.

Git n'est pas fait pour les données. Un CSV de 2 Go committé reste dans l'historique pour toujours, même supprimé ensuite : chaque clone le retéléchargera. Mettez 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.toml centralise tout.
  • UV donne un environnement reproductible en une commande : contraintes dans pyproject.toml, solution exacte dans uv.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.
  • *args ramasse les arguments positionnels en tuple, **kwargs les 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 pour f = 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=True protège des mutations silencieuses, slots=True des 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.
  • yield met 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.

PythonDécorateursDataclassesGénérateursUVpre-commit