Confiance et intégrité des artefacts
- Leçon 3
- 45 min
- Module 1 : Définir le contrat d'inférence
Leçon 3 sur 15 · Déployer des modèles d'IA pour de vrais utilisateursConfiance et intégrité des artefacts
1. Définir le contrat d'inférence
2. Choisir un mode de service
3. Créer le service de prédiction
4. Choisir l'infrastructure et mesurer
5. Mettre en production, surveiller et rétablir
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 main
Ce que vous devez savoir
- Le paquet et ses quatre fichiers, vus dans Empaqueter les dépendances du modèle.
- Un hachage SHA-256 comme empreinte et un fichier pickle qui exécute du code : l’étude de cas Servir un modèle de prix des maisons : partie 2 du cours Créer et déployer votre première API.
- Une empreinte d’image, vue dans Construire efficacement : fixer les versions du cours L’essentiel de Docker et du nuage pour les développeurs en IA.
Ce qu’il vous faut sur votre ordinateur
~/projects/escalation-serviceavecescalation/contract.py,escalation/bundle.py,make_bundle.pyetbundles/escalation-1.0.0/, vus dans Empaqueter les dépendances du modèle : créer le paquet.tests/conftest.pyettests/test_contract.py, vus dans Entrées et sorties des prédictions : tester le contrat.- Si votre projet est absent ou ne fonctionne plus, vous pouvez copier le module terminé,
end-of-m01, et suivre la lecture. Utiliser un instantané présente les étapes.
Testé avec Python 3.14.6 sur macOS, avec une puce Apple. Les étapes pour Windows et Linux ne sont pas testées.
Un sceau sur un flacon de médicaments
Section intitulée « Un sceau sur un flacon de médicaments »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.
Le problème : un octet et du code qui s’exécute
Section intitulée « Le problème : un octet et du code qui s’exécute »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 :
"""Change one byte of a copy of the model, then compare hashes and scores."""
import hashlibfrom 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 bytechanged = bytearray(original)changed[position] ^= 0x80 # change one bit of one byte: the sign of the numberCOPY.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.3507changed d794091256b80c7d48c16b56fd37b300f639749a312cca46a344fb561c5afacb score 0.2007Bytes that differ: 1 of 5354Un 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.
L’idée : une empreinte pour chaque fichier
Section intitulée « L’idée : une empreinte pour chaque fichier »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 :
import hashlib
for text in ["threshold 0.19", "threshold 0.18"]: print(text, hashlib.sha256(text.encode()).hexdigest())La sortie :
threshold 0.19 a3ab6ef04db90010e235ae18413f3f93b37b44558f6b1676a22ca071a2cf3f17threshold 0.18 442296fdd032932d01be065454160a7fb142bd90c52e1d01029b07e7cf238c7fLe 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.
La provenance : d’où vient l’empreinte
Section intitulée « La provenance : d’où vient l’empreinte »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.
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.
Six barrières, dans l’ordre
Section intitulée « Six barrières, dans l’ordre »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.
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.
Écrire le chargeur
Section intitulée « Écrire le chargeur »Remplacez escalation/bundle.py par le chargeur complet. La première partie contient les noms, l’erreur et la classe Bundle :
"""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 hashlibimport jsonfrom dataclasses import dataclassfrom importlib.metadata import versionfrom pathlib import Path
import joblibimport numpy as npimport 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."""
@dataclassclass 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 :
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 bundleLa 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.
Recréer le paquet, avec son empreinte
Section intitulée « Recréer le paquet, avec son empreinte »make_bundle.py doit maintenant écrire SHA256SUMS et afficher l’empreinte. Faites trois changements dans ce fichier :
-
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, theparity cases and SHA256SUMS. It prints the bundle digest: give it to theservice as MODEL_SHA256. -
Modifiez l’importation depuis
escalation.bundle:make_bundle.py from escalation.bundle import FILES, PACKAGES, bundle_digest, sha256 -
À la fin de
main(), après la ligne qui écritmetadata.json, écrivezSHA256SUMSet affichez l’empreinte. La fin demain()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.
-
Supprimez l’ancien dossier.
Fenêtre de terminal rm -r bundles/escalation-1.0.0Fenêtre de terminal Remove-Item -Recurse bundles\escalation-1.0.0 -
Créez le paquet.
Terminal python make_bundle.py ../ticket-model --version 1.0.0La sortie, pour le paquet du cours :
Made bundles/escalation-1.0.0: 14 parity cases, threshold 0.19.Bundle digest (MODEL_SHA256): 8f233029aa4c896dce1a5b3dfa970cd085088386bbe7784519f15a5b1a433b74Votre empreinte est différente. Votre
metadata.jsoncontient votre propretrained_at, donc son hachage,SHA256SUMSet l’empreinte diffèrent. Seul le paquet du cours dans les laboratoires a l’empreinte8f233029…. Copiez votre empreinte; les prochaines étapes l’utilisent. -
Examinez
bundles/escalation-1.0.0/SHA256SUMS. Il contient quatre lignes, une par fichier. Votre lignemetadata.jsonest différente de celle-ci :bundles/escalation-1.0.0/SHA256SUMS a4acd259a398e7c84517b98bf50d811197f90b0f2f1ea15f1f9e168ba3aff736 model.joblibeff05c0f94b86a95d4f610d397d2f7914e1407de3695532d105f71bfbfd9c9d8 contract.json9b281b1c04b90567b621bedfc152956600a4ab66bd24bfe0fc803c94b8d719ca metadata.json6aad77934a0f4bcabb0bd450ef419528519646bec9b2879229b3a646a5f5f748 parity_cases.csv -
Chargez le paquet dans
python, avec votre empreinte à la place de celle du cours :Python from escalation.bundle import load_bundlebundle = load_bundle("bundles/escalation-1.0.0", "8f233029aa4c896dce1a5b3dfa970cd085088386bbe7784519f15a5b1a433b74")print(bundle.version, bundle.threshold, bundle.digest[:12])La sortie :
1.0.0 0.19 8f233029aa4cVotre ligne montre les 12 premiers caractères de votre propre empreinte.
-
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 :
"""Harmless: the only code in the file is a call to print."""
import hashlibimport shutilfrom 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 downcopy = 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 made3. 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.
Tester le chargeur
Section intitulée « Tester le chargeur »Les tests utilisent le paquet du projet. Ajoutez deux lignes à tests/conftest.py, après import pytest, et l’importation nécessaire en haut :
from pathlib import Path
import pytest
ROOT = Path(__file__).parent.parentBUNDLE = 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 :
import jsonimport shutil
import pytest
from escalation.bundle import BundleError, bundle_digest, load_bundlefrom tests.conftest import BUNDLE
@pytest.fixturedef 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"]) == 11test_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 :
python -m pytestLa sortie (abrégée) :
tests/test_bundle.py ...... [ 33%]tests/test_contract.py ............ [100%]
============================== 18 passed in 1.72s ==============================Vérifier un fichier téléchargé
Section intitulée « Vérifier un fichier téléchargé »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 :
shasum -a 256 ../ticket-model/model/escalation_model.joblib(Get-FileHash ..\ticket-model\model\escalation_model.joblib).Hash.ToLower()La sortie sur macOS :
a4acd259a398e7c84517b98bf50d811197f90b0f2f1ea15f1f9e168ba3aff736 ../ticket-model/model/escalation_model.joblibSi 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.
Votre réponse contient-elle les points principaux de la nôtre ?
Connectez-vous et inscrivez-vous au cours pour enregistrer vos réponses. Chaque vérification compte pour le certificat.
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.
Votre réponse contient-elle les points principaux de la nôtre ?
Connectez-vous et inscrivez-vous au cours pour enregistrer vos réponses. Chaque vérification compte pour le certificat.
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.
Votre réponse contient-elle les points principaux de la nôtre ?
Connectez-vous et inscrivez-vous au cours pour enregistrer vos réponses. Chaque vérification compte pour le certificat.
Exercices du module
Section intitulée « Exercices du module »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 danscontract.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 sansprior_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 :
export MODEL_SHA256=$(shasum -a 256 bundles/escalation-1.0.0/SHA256SUMS | cut -c1-64)$env:MODEL_SHA256 = (Get-FileHash bundles\escalation-1.0.0\SHA256SUMS).Hash.ToLower()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
"""Score one ticket from a JSON file, or reject it before the model runs.
python check_ticket.py practice/good.json"""
import jsonimport osimport sysfrom pathlib import Path
from pydantic import ValidationError
from escalation.bundle import load_bundlefrom 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 yetexcept 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.0T-120001: score 0.4215, flag True, model 1.0.0Warning: channel: 'social' is a value the model never sawRejected before inference: created_at, customer_id, prior_escalations_90dLe 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érification du module
Section intitulée « Vérification du module »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.
Votre réponse contient-elle les points principaux de la nôtre ?
Connectez-vous et inscrivez-vous au cours pour enregistrer vos réponses. Chaque vérification compte pour le certificat.
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.
Votre réponse contient-elle les points principaux de la nôtre ?
Connectez-vous et inscrivez-vous au cours pour enregistrer vos réponses. Chaque vérification compte pour le certificat.
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é.
Votre réponse contient-elle les points principaux de la nôtre ?
Connectez-vous et inscrivez-vous au cours pour enregistrer vos réponses. Chaque vérification compte pour le certificat.
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_bundlevérifie l’empreinte, les hachages des fichiers et les versions avantjoblib.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