Aller au contenu
English

Confiance et intégrité des artefacts

  • Leçon 3
  • 45 min
  • Module 1 : Définir le contrat d'inférence

Ce que vous saurez faire

Chargez un paquet de modèle seulement après avoir prouvé qu’il s’agit exactement du paquet attendu. Un octet modifié donne des scores erronés, et un fichier de modèle peut exécuter du code.

Avant de commencerCe qu’il faut savoir et avoir sous la mainEnviron 45 minutes

Ce que vous devez savoir

Ce qu’il vous faut sur votre ordinateur

Testé avec Python 3.14.6 sur macOS, avec une puce Apple. Les étapes pour Windows et Linux ne sont pas testées.

Un nouveau flacon de médicaments a un sceau sous son bouchon. Si le sceau est brisé, vous ne prenez pas le médicament, même s’il semble normal. Vous ne pouvez pas voir un changement dans un comprimé. Vous vérifiez donc le sceau. Et vous faites confiance au sceau parce qu’il vient de l’usine, pas de la personne qui vous a donné le flacon.

C’est pareil pour un fichier de modèle. Vous ne pouvez pas y voir un changement. Un fichier modifié peut encore sembler normal et fonctionner normalement.

Amira copie model.joblib et modifie un seul octet de la copie : celui qui contient le signe d’un coefficient. C’est le coefficient pour team = payment. Puis elle calcule le score du billet de soutien d’exemple avec les deux fichiers. Le script est one_byte.py, dans le dossier du projet :

one_byte.py
"""Change one byte of a copy of the model, then compare hashes and scores."""
import hashlib
from pathlib import Path
import joblib
from escalation.contract import TicketIn, to_frame
MODEL = Path("bundles/escalation-1.0.0/model.joblib")
COPY = Path("changed.joblib")
original = MODEL.read_bytes()
pipeline = joblib.load(MODEL)
position = original.find(pipeline[-1].coef_.tobytes()) + 7 * 8 + 7 # coefficient 8, its last byte
changed = bytearray(original)
changed[position] ^= 0x80 # change one bit of one byte: the sign of the number
COPY.write_bytes(changed)
example = TicketIn.model_config["json_schema_extra"]["examples"][0]
frame = to_frame([TicketIn(**example)])
for name, data, path in [("original", original, MODEL), ("changed ", bytes(changed), COPY)]:
score = joblib.load(path).predict_proba(frame)[0, 1]
print(f"{name} {hashlib.sha256(data).hexdigest()} score {score:.4f}")
print(f"Bytes that differ: {sum(a != b for a, b in zip(original, changed))} of {len(original)}")
COPY.unlink()

Exécutez-le avec python one_byte.py. La sortie :

original a4acd259a398e7c84517b98bf50d811197f90b0f2f1ea15f1f9e168ba3aff736 score 0.3507
changed d794091256b80c7d48c16b56fd37b300f639749a312cca46a344fb561c5afacb score 0.2007
Bytes that differ: 1 of 5354

Un octet sur 5 354, et le score est passé de 0,3507 à 0,2007. Le fichier modifié s’est chargé, a calculé le score et n’a donné aucun avertissement. Ce billet est encore signalé, juste au-dessus de 0,19. Sur les 1 400 billets de juillet, le fichier modifié en signale 15,6 % plutôt que 20,1 %. Et 64 billets perdent leur signalement. Un disque endommagé, une mauvaise copie ou une personne pourrait causer ce changement. Personne ne le verrait dans les scores.

Le deuxième problème est pire. Les cours Apprentissage automatique : du problème à un modèle fiable et Créer et déployer votre première API l’ont montré. Un fichier joblib contient des instructions, et son chargement les exécute. Un fichier inoffensif peut appeler print pendant son chargement. Un fichier dangereux peut appeler n’importe quoi, avec les droits de votre service. La vérification doit donc précéder joblib.load. Vérifier les scores après le chargement arrive trop tard.

Un hachage est une empreinte d’un ensemble d’octets : SHA-256 lit chaque octet et renvoie 64 caractères. Les mêmes octets donnent toujours le même hachage. Des octets différents donnent un hachage complètement différent, même si un seul caractère change :

Python
import hashlib
for text in ["threshold 0.19", "threshold 0.18"]:
print(text, hashlib.sha256(text.encode()).hexdigest())

La sortie :

threshold 0.19 a3ab6ef04db90010e235ae18413f3f93b37b44558f6b1676a22ca071a2cf3f17
threshold 0.18 442296fdd032932d01be065454160a7fb142bd90c52e1d01029b07e7cf238c7f

Le fichier de modèle a montré la même chose : a4acd259… est devenu d7940912…. Personne ne peut modifier un fichier et conserver son hachage. Si vous connaissez le bon hachage, vous pouvez donc vérifier un fichier avant de l’ouvrir.

Un paquet contient quatre fichiers. Leurs quatre hachages vont dans un fichier supplémentaire, SHA256SUMS, à raison d’une ligne par fichier. Le hachage de SHA256SUMS devient alors une empreinte du paquet entier : l’empreinte du paquet. Modifier un octet dans n’importe quel fichier change son hachage, donc SHA256SUMS, puis l’empreinte. C’est le même principe qu’une empreinte d’image dans L’essentiel de Docker et du nuage pour les développeurs en IA. Les 64 caractères désignent un contenu exact.

Où le service obtient-il la bonne empreinte? Pas dans le paquet. Pensez à la personne qui a modifié model.joblib. Elle peut aussi calculer le nouveau hachage et l’écrire dans SHA256SUMS. Une vérification avec une valeur à l’intérieur du paquet réussit alors. Le sceau brisé a été remplacé par un nouveau sceau.

L’empreinte attendue doit donc venir d’un endroit fiable à l’extérieur du paquet. Dans ce cours, c’est le paramètre MODEL_SHA256. Priya le note quand elle crée le paquet. Une personne le révise et l’approuve dans le registre de mise en production (module 5) et les paramètres de déploiement. Pour changer le modèle, il faut changer le paquet et ce paramètre révisé.

C’est la provenance : qui a créé un artefact, à partir de quoi, et comment vous l’avez obtenu. Une partie se trouve dans le paquet. metadata.json consigne la date d’entraînement du modèle, les hachages de ses données d’entraînement et les versions de ses paquets. La preuve que le paquet est bien celui de Priya vient de l’extérieur.

Le dossier ticket-model/model/ du cours Apprentissage automatique : du problème à un modèle fiable passe par python make_bundle.py --version 1.0.0. Il devient le paquet versionné bundles/escalation-1.0.0/ avec cinq fichiers : model.joblib, contract.json, metadata.json, parity_cases.csv et SHA256SUMS. escalation-service lit le paquet avec load_bundle au démarrage. L'empreinte MODEL_SHA256, 8f233029aa4c..., est le hachage de SHA256SUMS. Elle est conservée dans un endroit fiable à l'extérieur du paquet et fixée pour le service.
L'empreinte est fixée pour le service depuis l'extérieur du paquet. Changer le modèle demande un changement à deux endroits, dont un fait l'objet d'une révision.
Description textuelle du diagramme

Le schéma de la leçon précédente. make_bundle.py transforme le dossier model/ du cours Apprentissage automatique : du problème à un modèle fiable en bundles/escalation-1.0.0/. Il contient model.joblib, contract.json, metadata.json, parity_cases.csv et SHA256SUMS, qui contient le hachage de chaque autre fichier. escalation-service lit le paquet avec load_bundle au démarrage. L’empreinte, 8f233029aa4c..., est le hachage de SHA256SUMS. Elle est conservée dans un endroit fiable à l’extérieur du paquet, sous MODEL_SHA256. Une flèche pointillée montre qu’elle est fixée pour le service.

load_bundle vérifie un paquet en six étapes. Chaque étape est une barrière. Si elle échoue, le chargeur lève BundleError avec un message qui nomme la barrière, et le service ne démarre pas. L’ordre est essentiel.

load_bundle reçoit un dossier et MODEL_SHA256. D'abord, trois barrières vérifient les octets, alors que rien n'est encore chargé. 1, l'empreinte : le hachage de SHA256SUMS est-il égal à MODEL_SHA256? 2, les hachages des fichiers : chaque fichier correspond-il à SHA256SUMS? 3, les versions : les paquets installés sont-ils ceux de metadata.json? Si toutes les réponses sont oui, l'étape 4 est joblib.load, seulement maintenant. Puis deux barrières vérifient le modèle chargé. 5, les variables : le pipeline liste-t-il les mêmes variables que le contrat? 6, l'autovérification : les scores des 14 cas de parité sont-ils à moins de 1e-9 des scores attendus? Si toutes les réponses sont oui, le paquet est prêt à calculer des scores. Un non à une barrière mène à l'arrêt : BundleError, le service ne démarre pas, et le message nomme la barrière.
Le chargeur vérifie les octets avant de charger quoi que ce soit, puis le comportement du modèle. Un non à une barrière arrête le service.
Description textuelle du diagramme

load_bundle reçoit un dossier et l’empreinte attendue, MODEL_SHA256. Trois barrières vérifient les octets alors que rien n’est encore chargé. Barrière 1, l’empreinte : le hachage de SHA256SUMS doit être égal à MODEL_SHA256; sinon, « mauvais paquet ». Barrière 2, les hachages des fichiers : chaque fichier doit correspondre à sa ligne dans SHA256SUMS; sinon, « un fichier a changé ». Barrière 3, les versions : les versions installées de scikit-learn, numpy, pandas et joblib doivent correspondre à metadata.json; sinon, « autres versions ».

Seulement ensuite vient l’étape 4 : joblib.load. Deux barrières vérifient le modèle chargé. Barrière 5, les variables : feature_names_in_ du pipeline doit être égal à la liste du contrat et à FEATURES; sinon, « autres variables ». Barrière 6, l’autovérification : les scores des 14 cas de parité doivent être à moins de 0,000000001 des scores attendus; sinon, « autres scores ». Si chaque barrière dit oui, le paquet est prêt à calculer des scores. Un non à une barrière lève BundleError, et le service ne démarre pas.

Les barrières 1 et 2 viennent d’abord parce qu’elles ont seulement besoin d’octets, dont la lecture est sûre. La barrière 3 précède le chargement. Un objet sérialisé avec pickle sous d’autres versions peut se charger, mais calculer différemment. Les barrières 5 et 6 ont besoin du pipeline chargé, donc elles viennent en dernier. L’autovérification prouve aussi que toute la chaîne fonctionne. Ces paquets, ce pipeline et ce contrat donnent les mêmes scores qu’à l’entraînement.

Tomás demande : « La barrière 1 prouve déjà les octets. Pourquoi la barrière 2? » La barrière 1 prouve que SHA256SUMS est la bonne liste. La barrière 2 prouve que les fichiers correspondent à la liste. Sans la barrière 2, un model.joblib modifié à côté du SHA256SUMS original passerait.

Remplacez escalation/bundle.py par le chargeur complet. La première partie contient les noms, l’erreur et la classe Bundle :

escalation/bundle.py
"""Load a model bundle safely, and refuse a bundle that is not the one we expect.
A bundle is a folder with everything the model needs to run:
model.joblib the fitted pipeline (preprocessing + model)
contract.json the features, their order and rules, and the threshold
metadata.json the version, the training data and the tested versions
parity_cases.csv fixed tickets and the scores they got in training
SHA256SUMS the SHA-256 of each file above
A joblib file can run code when it is loaded. So the checks run in this order,
and nothing is unpickled until the bytes are proven to be the expected ones.
"""
import hashlib
import json
from dataclasses import dataclass
from importlib.metadata import version
from pathlib import Path
import joblib
import numpy as np
import pandas as pd
from escalation.contract import FEATURES
FILES = ["model.joblib", "contract.json", "metadata.json", "parity_cases.csv"]
PACKAGES = ["scikit-learn", "numpy", "pandas", "joblib"]
TOLERANCE = 1e-9
class BundleError(Exception):
"""The bundle is not safe or not right to serve. The service must not start."""
@dataclass
class Bundle:
path: Path
digest: str
pipeline: object
contract: dict
metadata: dict
@property
def version(self) -> str:
return self.metadata["model_version"]
@property
def threshold(self) -> float:
return self.contract["output"]["threshold"]
@property
def known(self) -> dict[str, list[str]]:
return self.contract["known_categories"]

La deuxième partie, à la fin du même fichier, contient les barrières et load_bundle :

escalation/bundle.py (continued)
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def bundle_digest(path: Path) -> str:
"""One fingerprint for the whole bundle: the SHA-256 of its SHA256SUMS file."""
return sha256(path / "SHA256SUMS")
def check_files(path: Path, expected_digest: str) -> str:
"""Step 1 and 2: the bundle is the one we pinned, and no file has changed."""
sums = path / "SHA256SUMS"
if not sums.is_file():
raise BundleError(f"{path}: no SHA256SUMS file")
digest = bundle_digest(path)
if digest != expected_digest:
raise BundleError(
f"{path}: the bundle digest is {digest[:12]}..., "
f"but MODEL_SHA256 expects {expected_digest[:12]}..."
)
for line in sums.read_text(encoding="utf-8").splitlines():
expected, name = line.split()
if sha256(path / name) != expected:
raise BundleError(f"{path / name}: the file changed after the bundle was made")
return digest
def check_versions(metadata: dict) -> None:
"""Step 3: the packages here are the versions the bundle was tested with."""
for package in PACKAGES:
tested = metadata["versions"][package]
installed = version(package)
if installed != tested:
raise BundleError(
f"{package} {installed} is installed, "
f"but the bundle was tested with {tested}"
)
def self_check(bundle: Bundle) -> float:
"""Step 5: score the parity cases and compare with the training scores."""
cases = pd.read_csv(
bundle.path / "parity_cases.csv", keep_default_na=False, na_values=[""]
)
scores = bundle.pipeline.predict_proba(cases[FEATURES])[:, 1]
largest = float(np.abs(scores - cases["expected_score"]).max())
if largest > TOLERANCE:
raise BundleError(
f"self-check failed: scores differ from the bundle's by up to {largest:.1e}"
)
return largest
def load_bundle(path: str | Path, expected_digest: str) -> Bundle:
path = Path(path)
digest = check_files(path, expected_digest)
contract = json.loads((path / "contract.json").read_text(encoding="utf-8"))
metadata = json.loads((path / "metadata.json").read_text(encoding="utf-8"))
check_versions(metadata)
pipeline = joblib.load(path / "model.joblib") # step 4: only now
fitted = list(pipeline.feature_names_in_)
if fitted != contract["features"] or fitted != FEATURES:
raise BundleError("the pipeline, the contract and this service list different features")
bundle = Bundle(path, digest, pipeline, contract, metadata)
self_check(bundle)
return bundle

La tolérance est de 0,000000001, pas de 0. Un score calculé sur un autre ordinateur peut différer au dernier de ses 16 chiffres. Les nombres sont additionnés dans un ordre légèrement différent. Le module 3 mesure cet écart. Un vrai changement, comme l’octet modifié plus haut, déplace un score de 0,15.

make_bundle.py doit maintenant écrire SHA256SUMS et afficher l’empreinte. Faites trois changements dans ce fichier :

  1. Dans la chaîne de documentation, remplacez la fin du paragraphe qui commence par « Reads » (lit) par :

    make_bundle.py
    bundles/escalation-<version>/ with the model, its contract, its metadata, the
    parity cases and SHA256SUMS. It prints the bundle digest: give it to the
    service as MODEL_SHA256.
  2. Modifiez l’importation depuis escalation.bundle :

    make_bundle.py
    from escalation.bundle import FILES, PACKAGES, bundle_digest, sha256
  3. À la fin de main(), après la ligne qui écrit metadata.json, écrivez SHA256SUMS et affichez l’empreinte. La fin de main() devient :

    make_bundle.py
    (out / "metadata.json").write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8")
    lines = [f"{sha256(out / name)} {name}" for name in FILES]
    (out / "SHA256SUMS").write_text("\n".join(lines) + "\n", encoding="utf-8")
    print(f"Made {out}: {len(cases)} parity cases, threshold {info['threshold']}.")
    print(f"Bundle digest (MODEL_SHA256): {bundle_digest(out)}")

La version 1.0.0 existe déjà, et on ne modifie jamais une version. Ici, vous pouvez la recréer une fois, car la version 1.0.0 n’a jamais été mise en production. Aucun service, paramètre ou journal ne la désigne encore. Après une mise en production, il faudrait une nouvelle version.

  1. Supprimez l’ancien dossier.

    Fenêtre de terminal
    rm -r bundles/escalation-1.0.0
  2. Créez le paquet.

    Terminal
    python make_bundle.py ../ticket-model --version 1.0.0

    La sortie, pour le paquet du cours :

    Made bundles/escalation-1.0.0: 14 parity cases, threshold 0.19.
    Bundle digest (MODEL_SHA256): 8f233029aa4c896dce1a5b3dfa970cd085088386bbe7784519f15a5b1a433b74

    Votre empreinte est différente. Votre metadata.json contient votre propre trained_at, donc son hachage, SHA256SUMS et l’empreinte diffèrent. Seul le paquet du cours dans les laboratoires a l’empreinte 8f233029…. Copiez votre empreinte; les prochaines étapes l’utilisent.

  3. Examinez bundles/escalation-1.0.0/SHA256SUMS. Il contient quatre lignes, une par fichier. Votre ligne metadata.json est différente de celle-ci :

    bundles/escalation-1.0.0/SHA256SUMS
    a4acd259a398e7c84517b98bf50d811197f90b0f2f1ea15f1f9e168ba3aff736 model.joblib
    eff05c0f94b86a95d4f610d397d2f7914e1407de3695532d105f71bfbfd9c9d8 contract.json
    9b281b1c04b90567b621bedfc152956600a4ab66bd24bfe0fc803c94b8d719ca metadata.json
    6aad77934a0f4bcabb0bd450ef419528519646bec9b2879229b3a646a5f5f748 parity_cases.csv
  4. Chargez le paquet dans python, avec votre empreinte à la place de celle du cours :

    Python
    from escalation.bundle import load_bundle
    bundle = load_bundle("bundles/escalation-1.0.0", "8f233029aa4c896dce1a5b3dfa970cd085088386bbe7784519f15a5b1a433b74")
    print(bundle.version, bundle.threshold, bundle.digest[:12])

    La sortie :

    1.0.0 0.19 8f233029aa4c

    Votre ligne montre les 12 premiers caractères de votre propre empreinte.

  5. Donnez-lui maintenant l’empreinte d’une autre version, 1.1.0, du module 5 :

    Python (continued)
    load_bundle("bundles/escalation-1.0.0", "382077148b6cad58238e4832f0951c6556f82832bc6e7de1d3a2335bd6d36af0")

    La sortie (la dernière ligne; la première empreinte est la vôtre) :

    escalation.bundle.BundleError: bundles/escalation-1.0.0: the bundle digest is 8f233029aa4c..., but MODEL_SHA256 expects 382077148b6c...

    La barrière 1 a arrêté le chargement. Quittez Python avec exit().

Essayer les barrières avec un fichier qui semble dangereux

Section intitulée « Essayer les barrières avec un fichier qui semble dangereux »

Dernière démonstration du danger : un fichier qui appelle print pendant son chargement. Il est volontairement inoffensif : son seul code est print. Mettez-le dans une copie du paquet, puis essayez de charger la copie de trois façons. Créez surprise.py dans le dossier du projet :

surprise.py
"""Harmless: the only code in the file is a call to print."""
import hashlib
import shutil
from pathlib import Path
import joblib
from escalation.bundle import FILES, BundleError, bundle_digest, load_bundle
class Surprise:
def __reduce__(self): # pickle calls this function at load time
return (print, (" >>> This line ran while the file was loading.",))
PINNED = bundle_digest(Path("bundles/escalation-1.0.0")) # the digest you wrote down
copy = Path("surprise-bundle")
shutil.copytree("bundles/escalation-1.0.0", copy)
joblib.dump(Surprise(), copy / "model.joblib")
print("1. joblib.load on its own:")
joblib.load(copy / "model.joblib")
print("2. load_bundle, only model.joblib changed:")
try:
load_bundle(copy, PINNED)
except BundleError as error:
print(" Refused:", error)
print("3. load_bundle, SHA256SUMS rewritten to match:")
lines = [f"{hashlib.sha256((copy / name).read_bytes()).hexdigest()} {name}" for name in FILES]
(copy / "SHA256SUMS").write_text("\n".join(lines) + "\n", encoding="utf-8")
try:
load_bundle(copy, PINNED)
except BundleError as error:
print(" Refused:", error)
shutil.rmtree(copy)

Faites une prédiction avant de l’exécuter : dans lequel des trois essais la ligne >>> This line ran… (cette ligne s’est exécutée…) apparaît-elle? Puis exécutez python surprise.py. La sortie (vos empreintes diffèrent) :

1. joblib.load on its own:
>>> This line ran while the file was loading.
2. load_bundle, only model.joblib changed:
Refused: surprise-bundle/model.joblib: the file changed after the bundle was made
3. load_bundle, SHA256SUMS rewritten to match:
Refused: surprise-bundle: the bundle digest is 9a3bb70e16ba..., but MODEL_SHA256 expects 8f233029aa4c...

Seul l’appel direct à joblib.load a exécuté le code du fichier. À l’essai 2, la barrière 2 a trouvé le fichier modifié. À l’essai 3, l’« attaquant » a rendu le paquet cohérent avec lui-même. La barrière 1 l’a quand même refusé, car l’empreinte fixée venait de l’extérieur du paquet. Dans aucun de ces deux essais, load_bundle n’a atteint joblib.load. Supprimez surprise.py et one_byte.py quand vous avez terminé; ils ne font pas partie du projet.

Les tests utilisent le paquet du projet. Ajoutez deux lignes à tests/conftest.py, après import pytest, et l’importation nécessaire en haut :

tests/conftest.py
from pathlib import Path
import pytest
ROOT = Path(__file__).parent.parent
BUNDLE = ROOT / "bundles" / "escalation-1.0.0"

Puis créez tests/test_bundle.py. Chaque test fait échouer une barrière dans une copie du paquet :

tests/test_bundle.py
import json
import shutil
import pytest
from escalation.bundle import BundleError, bundle_digest, load_bundle
from tests.conftest import BUNDLE
@pytest.fixture
def copy(tmp_path):
target = tmp_path / "bundle"
shutil.copytree(BUNDLE, target)
return target
def test_the_pinned_bundle_loads():
bundle = load_bundle(BUNDLE, bundle_digest(BUNDLE))
assert bundle.version == "1.0.0"
assert bundle.threshold == 0.19
def test_a_wrong_digest_stops_before_loading(copy, monkeypatch):
loaded = []
monkeypatch.setattr("escalation.bundle.joblib.load", lambda path: loaded.append(path))
with pytest.raises(BundleError, match="MODEL_SHA256 expects"):
load_bundle(copy, "0" * 64)
assert loaded == []
def test_a_changed_file_is_found(copy):
digest = bundle_digest(copy)
with open(copy / "model.joblib", "ab") as model:
model.write(b"\0")
with pytest.raises(BundleError, match="changed after the bundle was made"):
load_bundle(copy, digest)
def test_other_package_versions_are_refused(copy, monkeypatch):
monkeypatch.setattr("escalation.bundle.version", lambda package: "0.0.1")
with pytest.raises(BundleError, match="was tested with"):
load_bundle(copy, bundle_digest(copy))
def test_a_self_check_failure_stops_the_bundle(copy):
cases = (copy / "parity_cases.csv").read_text(encoding="utf-8").splitlines()
header, first = cases[0], cases[1].rsplit(",", 1)
cases[1] = f"{first[0]},0.5"
(copy / "parity_cases.csv").write_text("\n".join(cases) + "\n", encoding="utf-8")
sums = [line for line in (copy / "SHA256SUMS").read_text().splitlines()]
import hashlib
new = hashlib.sha256((copy / "parity_cases.csv").read_bytes()).hexdigest()
sums = [f"{new} parity_cases.csv" if line.endswith("parity_cases.csv") else line for line in sums]
(copy / "SHA256SUMS").write_text("\n".join(sums) + "\n")
with pytest.raises(BundleError, match="self-check failed"):
load_bundle(copy, bundle_digest(copy))
def test_the_contract_lists_the_pipeline_features():
contract = json.loads((BUNDLE / "contract.json").read_text())
assert len(contract["features"]) == 11

test_a_wrong_digest_stops_before_loading remplace joblib.load par une fonction qui consigne seulement son appel. La liste reste vide : avec une mauvaise empreinte, aucun objet n’est désérialisé avec pickle. Les tests prennent l’empreinte attendue dans le paquet lui-même, car ils testent le chargeur, pas la mise en production. Le service du module 2 la prend dans MODEL_SHA256.

Exécutez tous les tests :

Terminal
python -m pytest

La sortie (abrégée) :

tests/test_bundle.py ...... [ 33%]
tests/test_contract.py ............ [100%]
============================== 18 passed in 1.72s ==============================

La même idée protège un fichier que vous téléchargez, comme le modèle du cours dans C17/start/model. Calculez son hachage avant de le charger. Comparez-le au hachage que le cours publie dans le README.md de ce dossier :

Fenêtre de terminal
shasum -a 256 ../ticket-model/model/escalation_model.joblib

La sortie sur macOS :

a4acd259a398e7c84517b98bf50d811197f90b0f2f1ea15f1f9e168ba3aff736 ../ticket-model/model/escalation_model.joblib

Si les hachages diffèrent, ne chargez pas le fichier. Téléchargez-le de nouveau. S’il est encore différent, signalez-le. Vous avez peut-être entraîné le modèle vous-même avec train.py du cours Apprentissage automatique : du problème à un modèle fiable. Votre hachage peut alors être différent, et ce n’est pas une erreur. Deux entraînements sur le même ordinateur, avec les mêmes versions, ont donné les mêmes octets. Sur un autre ordinateur ou système d’exploitation, personne ne le garantit.

Formats plus sûrs et pourquoi ce cours conserve joblib

Section intitulée « Formats plus sûrs et pourquoi ce cours conserve joblib »

Certains formats ne peuvent pas exécuter de code au chargement. skops (skops.io) enregistre les modèles scikit-learn dans un format qui liste les types contenus dans le fichier. skops.io.load charge seulement les types que vous désignez comme fiables et refuse les autres. ONNX stocke un modèle sous forme de graphe d’opérations mathématiques qu’un environnement d’exécution distinct exécute. Ce n’est pas du code Python, mais les pipelines scikit-learn ne se convertissent pas tous au format ONNX. Ces formats réduisent le risque au chargement. Ils ne prouvent pas que le fichier est celui que vous attendez.

Ce cours conserve joblib, car le cours d’apprentissage automatique l’a utilisé pour enregistrer le modèle. La plupart des exemples scikit-learn l’utilisent aussi. Pour un modèle créé par votre propre équipe, l’ordre des barrières rend joblib suffisamment sûr. La vérification du hachage confirme les octets avant toute exécution, et l’empreinte provient d’un endroit révisé. Si un modèle provient de l’extérieur de votre équipe, préférez un format comme skops et vérifiez aussi le hachage.

Erreur courante

Charger un fichier de modèle pour l'examiner

Symptôme : un collègue partage escalation_model_v2.joblib dans une conversation. Tomás veut voir ce que c’est. Il exécute donc joblib.load("escalation_model_v2.joblib") dans une session Python. Il n’y a aucun symptôme : le fichier se charge et ressemble à un pipeline normal. Si le fichier contenait du code, ce code s’est déjà exécuté avec les droits de Tomás, avant qu’il voie quoi que ce soit.

Cause : charger un fichier joblib ou pickle exécute les instructions qu’il contient. « Examiner » un fichier de cette façon revient à l’exécuter. La démonstration ci-dessus l’a montré avec print. Le fichier d’un véritable attaquant lirait des secrets ou installerait quelque chose.

Correction : ne chargez jamais un fichier de modèle que vous ne pouvez pas vérifier. Demandez d’où il vient. Obtenez son hachage d’une source fiable, comme le registre de mise en production ou la personne qui l’a entraîné, par un autre canal. Comparez les hachages avant de charger le fichier. Mieux encore : demandez un paquet et chargez-le seulement avec load_bundle, en utilisant une empreinte fixe. Pour examiner un fichier sans le charger, lisez metadata.json et contract.json : ce sont des fichiers texte.

Pour un vrai service d’IA : le risque augmente avec l’automatisation. Un service qui charge n’importe quel fichier d’un dossier de stockage exécute ce qu’y dépose toute personne ayant un accès en écriture. Fixez l’empreinte dans des paramètres révisés, afin qu’un nouveau modèle exige une modification révisée.

Vérifiez votre compréhension

Pourquoi l'empreinte attendue doit-elle provenir de l'extérieur du paquet? Que manquerait une vérification avec le fichier SHA256SUMS du paquet?

Afficher la réponse

Une personne qui modifie model.joblib peut aussi calculer son nouveau hachage et réécrire SHA256SUMS. La vérification avec le fichier SHA256SUMS du paquet réussit alors, car le paquet est cohérent avec lui-même (essai 3 de surprise.py). L’empreinte fixée dans MODEL_SHA256, dans les paramètres révisés, ne change pas avec le paquet. La barrière 1 refuse donc le paquet réécrit avant tout chargement.

Vérifiez votre compréhension

Tomás propose de déplacer joblib.load au début de load_bundle, puis de vérifier les hachages. Le résultat serait identique, dit-il. Est-ce vrai?

Afficher la réponse

Non. Le service refuserait un mauvais paquet dans les deux cas. Mais charger d’abord exécute tout code contenu dans le fichier avant la vérification. Le mal est alors déjà fait. Les hachages et les versions nécessitent seulement des octets et du texte brut. On peut donc les vérifier d’abord sans danger. joblib.load intervient seulement après la vérification des octets.

Vérifiez votre compréhension

Un octet de model.joblib a changé sur un disque. Quelle barrière bloque le paquet et quel message le service consigne-t-il?

Afficher la réponse

La barrière 2, celle des hachages des fichiers. SHA256SUMS n’a pas changé, donc la barrière 1 est franchie. Mais le hachage de model.joblib ne correspond plus à sa ligne. Le message est bundles/escalation-1.0.0/model.joblib: the file changed after the bundle was made (le fichier a changé après la création du paquet), et le service ne démarre pas.

Exercice guidé (réalisé dans ce module). Vous avez écrit le contrat et ses 12 tests. Vous avez créé le paquet escalation-1.0.0 avec ses quatre fichiers et SHA256SUMS. Vous l’avez chargé en passant par les six barrières, avec 18 tests. Vérifiez que votre projet est complet : python -m pytest indique 18 passed (18 tests réussis).

Tâche autonome : créer un paquet de modèle et valider son schéma d’entrée. Écrivez check_ticket.py dans le dossier du projet. Il lit un billet de soutien dans un fichier JSON. Il vérifie d’abord que le billet respecte le contrat. Sinon, il affiche les noms des champs problématiques et se termine avec le code 1, sans charger le paquet. Si le billet respecte le contrat, il charge le paquet avec load_bundle et l’empreinte de la variable d’environnement MODEL_SHA256. Ensuite, il affiche le score, l’indicateur, la version du modèle et tout avertissement.

Testez-le avec trois fichiers dans un dossier practice/ :

  • good.json : le billet donné en exemple dans contract.py.

  • social.json : le même billet avec "channel": "social".

  • from_csv.json : un billet de juillet tel qu’il apparaît dans le fichier CSV, avec ses colonnes supplémentaires et sans prior_escalations_90d :

    practice/from_csv.json
    {"ticket_id": "T-119233", "customer_id": "C-00029", "created_at": "2026-07-01 07:15:55",
    "channel": "chat", "team": "returns", "priority": 3, "segment": "trade", "region": "unknown",
    "order_value": 54.38, "word_count": 77, "customer_tenure_days": 748, "prior_tickets_90d": 2,
    "created_hour": 7}

Définissez d’abord l’empreinte de votre paquet :

Fenêtre de terminal
export MODEL_SHA256=$(shasum -a 256 bundles/escalation-1.0.0/SHA256SUMS | cut -c1-64)

Ici, vous calculez l’empreinte de votre propre paquet, que vous venez de créer et auquel vous faites confiance. Lors d’un déploiement, la valeur provient plutôt du registre de mise en production.

Afficher un exemple de réponse
check_ticket.py
"""Score one ticket from a JSON file, or reject it before the model runs.
python check_ticket.py practice/good.json
"""
import json
import os
import sys
from pathlib import Path
from pydantic import ValidationError
from escalation.bundle import load_bundle
from escalation.contract import TicketIn, to_frame, unknown_categories
body = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
try:
ticket = TicketIn(**body) # the contract first: nothing is loaded or scored yet
except ValidationError as error:
fields = sorted({str(e["loc"][0]) for e in error.errors()})
sys.exit(f"Rejected before inference: {', '.join(fields)}")
bundle = load_bundle("bundles/escalation-1.0.0", os.environ["MODEL_SHA256"])
score = bundle.pipeline.predict_proba(to_frame([ticket]))[0, 1]
print(f"{ticket.ticket_id}: score {score:.4f}, flag {bool(score >= bundle.threshold)}, model {bundle.version}")
for warning in unknown_categories(ticket, bundle.known):
print("Warning:", warning)

Les sorties de python check_ticket.py practice/good.json, practice/social.json et practice/from_csv.json :

T-120001: score 0.3507, flag True, model 1.0.0
T-120001: score 0.4215, flag True, model 1.0.0
Warning: channel: 'social' is a value the model never saw
Rejected before inference: created_at, customer_id, prior_escalations_90d

Le dernier se termine avec le code 1 (echo $? sur macOS et Linux, $LASTEXITCODE dans PowerShell). Deux problèmes sont des champs supplémentaires du fichier CSV. Le troisième est une variable ajoutée plus tard dans le cours d’apprentissage automatique, qui manque dans ce fichier. Le paquet n’a jamais été chargé pour ce billet. Avec un MODEL_SHA256 incorrect, le bon billet est bloqué à la barrière 1 avec BundleError.

Vérifiez votre compréhension

Nommez les fichiers du paquet escalation-1.0.0 et décrivez le rôle de chacun en une ligne.

Afficher la réponse

model.joblib : le pipeline entraîné, le prétraitement et le modèle. contract.json : les variables dans l’ordre, leurs règles, les catégories connues, les règles des valeurs manquantes et la sortie avec le seuil. metadata.json : la version, la date d’entraînement, les hachages des données, les résultats de validation et les versions testées des paquets. parity_cases.csv : 14 billets fixes avec leurs scores attendus. SHA256SUMS : le hachage de chacun des quatre fichiers; son propre hachage est l’empreinte du paquet.

Vérifiez votre compréhension

Prédisez : Priya entraîne un nouveau modèle sur des données plus récentes et remplace model.joblib dans bundles/escalation-1.0.0 par son fichier. Le service démarre avec l'ancien MODEL_SHA256. Que se passe-t-il et qu'aurait-elle dû faire?

Afficher la réponse

Le service ne démarre pas. SHA256SUMS n’a pas changé, donc la barrière 1 est franchie. Mais la barrière 2 détecte que model.joblib a changé après la création du paquet. load_bundle lève alors BundleError avant tout chargement. Elle aurait dû créer une nouvelle version avec make_bundle.py, par exemple --version 1.1.0. Cette version obtient un nouveau dossier, son propre seuil, ses cas de parité et son empreinte. Elle met ensuite cette empreinte en production au moyen des paramètres révisés.

Vérifiez votre compréhension

Vérification finale : montrez que des données de variables incompatibles sont rejetées avant l'inférence. Quelle preuve montre que le modèle ne s'est jamais exécuté?

Afficher la réponse

Exécutez python check_ticket.py practice/from_csv.json : il affiche Rejected before inference: created_at, customer_id, prior_escalations_90d (rejeté avant l’inférence, suivi des champs) et se termine avec le code 1. Voici la preuve que le modèle ne s’est jamais exécuté : check_ticket.py valide avec TicketIn avant d’appeler load_bundle. Pour un billet rejeté, il ne charge jamais le paquet et n’affiche aucun score. Dans le service, le même contrat renvoie 422 invalid_ticket (billet invalide) avec la liste fields (champs), comme l’explorateur de la leçon 1 l’a montré.

Récapitulatif

  • Un hachage est une empreinte des octets : un octet modifié a fait passer le score de 0,3507 à 0,2007, et le hachage de a4acd259… à d7940912….
  • L’empreinte du paquet est le hachage de SHA256SUMS. Elle désigne le paquet entier en 64 caractères.
  • La provenance indique qui a créé un artefact, à partir de quoi et comment vous l’avez obtenu. L’empreinte attendue provient d’un endroit fiable et révisé, à l’extérieur du paquet.
  • Charger un fichier joblib exécute son code. load_bundle vérifie l’empreinte, les hachages des fichiers et les versions avant joblib.load, puis les variables et les 14 cas de parité après.

Le module 1 en un paragraphe. Vous avez écrit le contrat d’inférence pour refuser un billet incorrect avant l’exécution du modèle, en indiquant le nom du champ. Vous avez empaqueté le pipeline complet avec son contrat, ses métadonnées, ses versions testées et ses cas de parité. Le tout se trouve dans un dossier dont la version ne change jamais. Vous le chargez seulement en passant par six barrières, avec une empreinte provenant de l’extérieur du paquet. Votre projet contient escalation/contract.py, escalation/bundle.py, make_bundle.py, bundles/escalation-1.0.0/ et 18 tests réussis.

Revenir à un état connu. Si votre projet ne fonctionne plus, remplacez-le par l’instantané end-of-m01 (Utiliser un instantané), puis exécutez python -m pytest : 18 passed. L’instantané contient le paquet du cours, donc son empreinte est 8f233029…. Le module 1 n’a créé aucun conteneur ni aucune image. Il n’y a donc rien à supprimer dans Docker.

Ensuite, vous utiliserez le paquet pour une première tâche : calculer les scores d’un fichier complet de billets chaque nuit.

Leçon suivante : Inférence par lots