Leçon 16 / 20

Réduire un coût confirmé par les mesures

20 min Python — typage, tests et performance Gratuit

Objectif : Améliorer l’agrégation identifiée comme coûteuse et vérifier, par une mesure reproductible, que la modification conserve le résultat tout en réduisant le temps d’exécution.

Le profilage a confirmé que l’agrégation des dépenses est le point à examiner. Une optimisation utile doit réduire ce coût sans modifier le total calculé : vous allez comparer deux parcours sur exactement les mêmes données, puis ne retenir le nouveau que si le résultat et la mesure le justifient.

Comprendre le coût du parcours

Dans le scénario du projet, vous disposez de dépenses représentées par des dictionnaires contenant un montant et une catégorie. Pour calculer le total de chaque catégorie, un parcours naïf peut rechercher toutes les dépenses pour chaque catégorie distincte. Avec n dépenses et jusqu’à n catégories, cette répétition peut demander un nombre de comparaisons proportionnel à n × n ; le coût augmente alors rapidement quand le jeu de données grandit.

Vous pouvez obtenir le même résultat en parcourant les dépenses une seule fois et en ajoutant chaque montant au total de sa catégorie. Le nombre d’opérations de traitement suit alors principalement le nombre de dépenses, auquel s’ajoute le coût d’accès aux clés du dictionnaire. Cette explication prédit une amélioration possible ; seule la mesure permet de savoir si elle est visible sur le scénario réel.

Comparer les deux traitements sur les mêmes données

Dans scripts/profiler.py, conservez le benchmark reproductible, les mesures de profilage cProfile et l’instrumentation line_profiler déjà présents. Remplacez le contenu du fichier par le script complet ci-dessous : il reconstruit à l’identique 10 000 dépenses déterministes, contrôle l’égalité des résultats et compare les temps des deux fonctions. Le profiling ligne par ligne reste disponible et cible la fonction optimisée.

scripts/profiler.pyTexte
from collections import defaultdict
from cProfile import Profile
from pstats import SortKey, Stats
from statistics import median
from timeit import repeat
from typing import TypedDict


class DepenseProfilage(TypedDict):
    montant: float
    categorie: str


def aggreger_montants_avant(
    depenses: list[DepenseProfilage],
) -> dict[str, float]:
    totaux: dict[str, float] = {}
    categories = {depense["categorie"] for depense in depenses}
    for categorie in categories:
        totaux[categorie] = sum(
            depense["montant"]
            for depense in depenses
            if depense["categorie"] == categorie
        )
    return totaux


def aggreger_montants(
    depenses: list[DepenseProfilage],
) -> dict[str, float]:
    totaux: defaultdict[str, float] = defaultdict(float)
    for depense in depenses:
        totaux[depense["categorie"]] += depense["montant"]
    return dict(totaux)


def construire_depenses(nombre: int = 10_000) -> list[DepenseProfilage]:
    categories = ("transport", "repas", "materiel", "hebergement")
    return [
        {
            "montant": float((index % 97) + 1) / 10,
            "categorie": categories[index % len(categories)],
        }
        for index in range(nombre)
    ]


def benchmark() -> None:
    depenses = construire_depenses()
    resultat_avant = aggreger_montants_avant(depenses)
    resultat_apres = aggreger_montants(depenses)
    assert resultat_avant == resultat_apres

    repetitions = 7
    nombre = 100
    temps_avant = repeat(
        lambda: aggreger_montants_avant(depenses),
        number=nombre,
        repeat=repetitions,
    )
    temps_apres = repeat(
        lambda: aggreger_montants(depenses),
        number=nombre,
        repeat=repetitions,
    )
    mediane_avant = median(temps_avant) / nombre
    mediane_apres = median(temps_apres) / nombre
    gain = mediane_avant / mediane_apres

    print(f"Résultats identiques : {resultat_avant == resultat_apres}")
    print(f"Avant : {mediane_avant:.6f} s par appel (médiane)")
    print(f"Après : {mediane_apres:.6f} s par appel (médiane)")
    print(f"Accélération mesurée : {gain:.2f}x")

    profileur = Profile()
    profileur.runcall(aggreger_montants, depenses)
    print("\nProfil cProfile de l'agrégation optimisée :")
    Stats(profileur).strip_dirs().sort_stats(SortKey.CUMULATIVE).print_stats(10)

    try:
        from line_profiler import LineProfiler
    except ImportError:
        print("\nline_profiler absent : installez-le pour le profilage ligne par ligne.")
    else:
        profileur_lignes = LineProfiler()
        profileur_lignes.add_function(aggreger_montants)
        profileur_lignes(aggreger_montants)(depenses)
        print("\nProfil ligne par ligne de l'agrégation optimisée :")
        profileur_lignes.print_stats()


if __name__ == "__main__":
    benchmark()

Lancez le scénario depuis la racine du projet avec python scripts/profiler.py. Les deux fonctions sont chronométrées séparément avec les mêmes dépenses ; la préparation des données et la vérification de l’égalité ne sont pas incluses dans les appels mesurés. Les durées exactes dépendent de la machine : attendez-vous à voir un tableau de totaux identique et, généralement, un temps inférieur pour le parcours unique, sans considérer un facteur précis comme garanti.

Forme du résultat attenduTexte
Résultats identiques : True
Avant : durée mesurée sur votre machine
Après : durée mesurée sur votre machine
Accélération mesurée : facteur calculé à partir des médianes

Profil cProfile de l'agrégation optimisée :
... lignes de statistiques, triées par temps cumulé ...

Profil ligne par ligne de l'agrégation optimisée :
... si line_profiler est installé ...

Interpréter et conserver le changement

La valeur « accélération mesurée » est le rapport entre les médianes avant et après. Elle résume cette comparaison, mais ne constitue ni une promesse pour toute charge ni un seuil universel. Si le gain est faible ou instable, répétez le scénario dans des conditions comparables et vérifiez que l’agrégation était bien le coût dominant ; évitez d’ajouter de la complexité pour un bénéfice non confirmé.

Le script mesure explicitement les deux versions et affiche le profil de la version optimisée. Les anciennes étapes de diagnostic restent disponibles : cProfile permet de vérifier où se situe le coût global et, si line_profiler est installé, l’instrumentation ligne par ligne montre la répartition locale. La prochaine étape consiste à fixer le périmètre de livraison et les critères permettant de valider l’outil de dépenses.

À retenir

  • Remplacez les recherches répétées par un parcours unique lorsque le profilage confirme le coût et que le calcul peut être reformulé ainsi.
  • Comparez les versions sur le même jeu déterministe et vérifiez l’égalité des résultats avant d’interpréter les temps.
  • Utilisez les médianes de plusieurs répétitions ; le gain observé dépend de la machine et des données.
  • Conservez une optimisation seulement si elle améliore une mesure pertinente sans régression fonctionnelle.

Envie d'aller plus loin ?

Les cours Enovatic sont gratuits et le resteront. Si vous cherchez un accompagnement encadré, nos formations professionnelles sont animées par les mêmes formateurs.