A modell csak akkor válik átadható artefaktummá, ha a tanító notebookból kilépve is önállóan, ellenőrizhetően betölthető. A betöltési határ (loading boundary) az a pont, ahol a modellfájl belép a futtató folyamatba: itt kell a hitelességet, a sémát, a függőségeket és a numerikus egyezést egy kapuval ellenőrizni. Ez az alfejezet az Adatverzió és splitmanifest splitmanifestjére épít, és előkészíti a Modelljelölt, jóváhagyás és registry registry-jóváhagyást.
A Northstar-előrejelzési projektben az Adatverzió
és splitmanifest alfejezetben rögzített spliten betanítottunk egy
scikit-learn Pipeline-t. A feladat: a betanított objektumot
a local_models/rendeles_keses_v1/ könyvtárba menteni úgy,
hogy egy teljesen új Python-processz csak akkor tudja működésbe
állítani, ha a betöltési kapun átment.
Bemenet: a betanított Pipeline, valamint 20 kontrollsor
a tanító DataFrame-ből. Megfigyelési egység: egy rendelés; a kontrollsor
egy rendelés feature-vektora. Kimenet: pipeline.joblib,
model_meta.json, control_predictions.npz. A
cél: az új processzben a kontrollpredikciók egyezzenek a mentéskori
értékekkel. Előzetes becslés: ugyanazon a gépen, azonos csomagverziókkal
a legnagyobb abszolút eltérés jellemzően
–
nagyságrendű, tehát
konzervatív kaput ad.
A modell-séma (model signature) nem egyszerűen az oszlopnevek
listája. Rögzíti a bemeneti oszlopok sorrendjét, típusát, a kategorikus
változók lehetséges szintjeit, valamint a kimenet alakját és jelentését.
Ha a betöltő processz a
distance_km, priority_flag, basket_minor, segment
oszlopokat más sorrendben adja, a ColumnTransformer
csendben rossz pozícióra illesztheti a numerikus értékeket, még akkor
is, ha a shape egyezik.
Példa egy szándékosan rövid sémára:
{
"artifact_version": "1",
"model_name": "rendeles_keses_base_v1",
"input_schema": {
"columns": ["distance_km", "priority_flag", "basket_minor", "segment"],
"categorical_levels": {"segment": ["business", "consumer"]}
},
"prediction_contract": {
"kind": "probability_of_class_1",
"shape": ["n_samples", 1],
"values": "float in [0, 1]"
},
"dependencies": {
"python": "<actual from environment>",
"scikit-learn": "<actual from environment>"
}
}A prediction_contract.shape itt
["n_samples", 1], mert a példában csak a
target_late = 1 osztály valószínűségét mentjük
kontrollként. A n_samples a kontrollminta sorainak száma,
nem egy rögzített konstans; ezt a metaadatban külön n mező
jelzi.
A joblib/pickle formátumú modellbetöltés tetszőleges kódfuttatást is
eredményezhet, ezért kizárólag megbízható, eredetileg hitelesített
artefaktumot szabad betölteni. A joblib-fájl önmagában nem elegendő a
teljes betanító környezet verzióinak dokumentálására. Ha a betöltő gépen
újabb pandas vagy scikit-learn fut, a deszerializálás lehet sikeres, de
a OneHotEncoder kategóriasorrendje, a lebegőpontos
szummázás vagy a fa implementációja megváltozhat. A metaadat ezért a
tényleges betanító verziókat rögzíti:
python, például 3.12.8;numpy, pandas, scikit-learn,
joblib.A verziótartomány önmagában nem elég. Az uv
projekt és ténylegesen rögzített függőségek uv.lock
fájlja a feloldott, pontos verziókat tartalmazza; a modellmeta akkor a
legerősebb, ha hivatkozik a lockfájl hashére is. A betöltési politikát a
csapat dönti el: szigorú módban verzióeltérésnél a kapu hibát ad,
megengedő módban figyelmeztet, ha a kontrollpredikció utána egyezik.
A joblib a pickle protokollt használja, a pickle deszerializálás pedig tetszőleges Python-kódot futtathat. Ezért a sorrend nem lehet „betöltés, majd ellenőrzés”, hanem:
joblib.load;A hash-ellenőrzés megbízhatósága annyi, amennyire a metaadat forrása
megbízható. Ha a model_meta.json egy aláírt Git-commitban
él, akkor a támadónak a commitot is hamisítania kellene. Ismeretlen
forrásból származó pipeline.joblib betöltése akkor is
kockázatos, ha a fájl kiterjesztése ismerős; a hash csak a fájl
sértetlenségét igazolja, nem a tartalom szándékát.
A háromrészes csomag szándékosan választja szét a felelősségeket:
| Fájl | Tartalom | Szerep |
|---|---|---|
pipeline.joblib |
szerializált pipeline | a tényleges modellobjektum |
model_meta.json |
séma, verziók, fájlhashek | betöltési szerződés, ember által olvasható |
control_predictions.npz |
kontrollsorok és predikciók | numerikus reprodukálhatósági kapu |
A joblib a numpy-tömböket hatékonyan tárolja, de nem hordoz sémaverziót; a JSON viszont verziózható és diffelhető, cserébe nem alkalmas nagyméretű tömbök tárolására. A kontrollpredikciókat ezért külön bináris fájlban tartjuk.
A betöltő processz a kontrollsorokra újra kiszámítja a predikciókat, és a mentett értékekkel hasonlítja össze. Az -edik kontrollsorra a feltétel:
ahol a kontrollsorok száma, a mentéskori modellkimenet, a betöltés utáni kimenet, pedig az abszolút tolerancia. A példában a pozitív osztály valószínűsége, tehát mértékegysége 1, tartománya , alakja skalár soronként.
Ha a predikció nem korlátos, például regressziós cél, akkor a lebegőpontos hiba nagyságrendfüggő, ezért a gyakorlatban használt bővebb alak:
ahol relatív tolerancia, tipikusan . Kézi kontroll: ha és , akkor a legnagyobb eltérés , ami alatt van, tehát a kapu nyit.
Az alábbi kód kizárólag szintetikus adatot generál, így nem függ a kezdőlabor CSV-itől. A kódot e szöveg írásakor nem futtattuk, ezért a kimenet „szemléltető várt” jelölést kap; a futtatás a helyi környezet pontos csomagverzióit írja a metaadatba.
# train_and_save.py
from pathlib import Path
import json, hashlib, sys
import numpy as np
import pandas as pd
import sklearn
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.linear_model import LogisticRegression
import joblib
rng = np.random.default_rng(20261020)
n = 300
df = pd.DataFrame({
"distance_km": rng.gamma(2.0, 50.0, n),
"priority_flag": rng.integers(0, 2, n),
"basket_minor": rng.integers(500, 20_000, n),
"segment": rng.choice(["consumer", "business"], n),
})
z = (0.02 * (df["distance_km"] - 100) / 100
+ 0.8 * df["priority_flag"]
+ 0.00005 * (df["basket_minor"] - 5000)
+ 0.5 * (df["segment"] == "business"))
prob = 1 / (1 + np.exp(-z))
df["target_late"] = (rng.random(n) < prob).astype(int)
feature_cols = ["distance_km", "priority_flag", "basket_minor", "segment"]
prep = ColumnTransformer([
("num", StandardScaler(), ["distance_km", "priority_flag", "basket_minor"]),
("cat", OneHotEncoder(handle_unknown="ignore"), ["segment"]),
])
model = Pipeline([
("prep", prep),
("clf", LogisticRegression(max_iter=1000, random_state=42)),
])
model.fit(df[feature_cols], df["target_late"])
artifact_dir = Path("local_models/rendeles_keses_v1")
artifact_dir.mkdir(parents=True, exist_ok=True)
model_file = artifact_dir / "pipeline.joblib"
meta_file = artifact_dir / "model_meta.json"
control_file = artifact_dir / "control_predictions.npz"
control = df[feature_cols].sample(20, random_state=7)
stored_pred = model.predict_proba(control)[:, 1]
joblib.dump(model, model_file)
np.savez(control_file,
controls=control.to_numpy(),
columns=np.array(feature_cols),
predictions=stored_pred)
meta = {
"artifact_version": "1",
"model_name": "rendeles_keses_base_v1",
"created_at": "2026-10-20T12:00:00+00:00",
"input_schema": {
"columns": feature_cols,
"categorical_levels": {"segment": sorted(df["segment"].unique())},
},
"prediction_contract": {
"kind": "probability_of_class_1",
"shape": ["n_samples", 1],
"values": "float in [0, 1]",
},
"dependencies": {
"python": sys.version.split()[0],
"numpy": np.__version__,
"pandas": pd.__version__,
"scikit-learn": sklearn.__version__,
"joblib": joblib.__version__,
},
"file_hashes": {
"pipeline.joblib": hashlib.sha256(model_file.read_bytes()).hexdigest(),
"control_predictions.npz": hashlib.sha256(control_file.read_bytes()).hexdigest(),
},
"control": {"n": len(control), "abs_tolerance": 1e-9},
}
meta_file.write_text(json.dumps(meta, indent=2), encoding="utf-8")
print("saved", artifact_dir)A betöltő script külön fájl, és külön processzben fut:
# check_model_artifact.py
import sys, json, hashlib
from pathlib import Path
import numpy as np
import pandas as pd
import joblib
EXPECTED_COLUMNS = ["distance_km", "priority_flag", "basket_minor", "segment"]
def main(artifact_dir: str) -> None:
p = Path(artifact_dir)
meta = json.loads((p / "model_meta.json").read_text(encoding="utf-8"))
# 1. Hitelességi kapu: hash a deszerializálás előtt.
for name in ["pipeline.joblib", "control_predictions.npz"]:
actual = hashlib.sha256((p / name).read_bytes()).hexdigest()
if actual != meta["file_hashes"][name]:
raise ValueError(f"hash mismatch: {name}")
# 2. Séma: a meta és a betöltő szerződése is egyezzen.
if meta["input_schema"]["columns"] != EXPECTED_COLUMNS:
raise ValueError("schema mismatch against service contract")
# 3. Betöltés csak a hash-ellenőrzés után.
model = joblib.load(p / "pipeline.joblib")
# 4. Kontrollpredikció numerikus kapuja.
loaded = np.load(p / "control_predictions.npz")
control = pd.DataFrame(loaded["controls"], columns=loaded["columns"])
new_pred = model.predict_proba(control)[:, 1]
stored_pred = loaded["predictions"]
diff = np.max(np.abs(new_pred - stored_pred))
if not np.allclose(new_pred, stored_pred, atol=meta["control"]["abs_tolerance"], rtol=0):
raise ValueError(f"prediction mismatch, max diff={diff}")
print(f"OK: hash és predikciós kontroll egyezik; hitelesség csak megbízható manifesttel, max diff={diff:.3e}")
if __name__ == "__main__":
main(sys.argv[1])Futtatási parancs Linux/macOS terminálból, projektgyökérből:
python check_model_artifact.py local_models/rendeles_keses_v1Windows PowerShellből:
python check_model_artifact.py local_models\rendeles_keses_v1Szemléltető várt kimenet, nem mért futási jegyzék:
OK: artifact authenticated, max diff=0.000e+00
A 0.000e+00 itt a formázás lehetséges eredménye;
tényleges futásnál jellemzően
–
nagyságrendű, de ez nem garantált seedek és platformok között.
A kapu csak azt igazolja, hogy a betöltött pipeline ugyanazt a függvényt számolja, mint a mentéskori objektum. Nem helyettesíti a modell tesztmetrikáját, és nem állít semmit a modell üzleti minőségéről. A három ellenőrzés három különböző hibát fog meg:
A max diff a toleranciához viszonyítva dönt; a pontos
küszöböt a predikciós szerződéshez kell igazítani. A példában a
valószínűség korlátos, ezért elegendő az abszolút tolerancia.
Regressziós modellnél a relatív tagot is hozzá kell venni a fenti képlet
szerint.
Értelmezés. Egy kolléga azt állítja: „Elég a
pipeline.joblib SHA-256 hashét ellenőrizni, hiszen a hash
garantálja, hogy a fájl biztonságos.” Miért hiányos ez az érvelés két
különböző okból is?
Megvalósítás. Egészítsd ki a metaadatot
output_schema mezővel, amely rögzíti, hogy a
predict_proba kimenete ["n_samples", 2] alakú,
és a második oszlop a target_late = 1 valószínűsége. Írd át
a betöltő scriptet úgy, hogy a model.predict_proba(control)
alakját és a model.classes_ listáját is
ellenőrizze.
Hibadiagnózis. A kolléga a betöltőben a
kontrollt így olvassa be:
control = pd.DataFrame(loaded["controls"]), oszlopnevek
nélkül. A script lefut, de a predikciós kapu
prediction mismatch hibát ad. Mi lehet az ok, és miért
veszélyes az a változat, amelyik véletlenül nem ad hibát?
Megváltozott feltétel. A modellt új gépre
telepítik, ahol a pandas verziója eltér a betanító környezettől. A
betöltő WARN üzenetet ír, a kontrollpredikció maximális eltérése
,
ami a
tolerancia fölött van. Írj strict_dependencies kapcsolót a
betöltőbe: ha true, a verzióeltérés hiba legyen; ha
false, a predikciós kapu döntsön. Milyen indoklással
engedhető át a modell ebben a laza módban?
1. megoldás. A hash-ellenőrzés két okból nem elég.
Egyrészt a hash a fájl sértetlenségét igazolja, de nem mond semmit a
fájl tartalmának szándékáról: ha a fájl eleve kártékony pickle-t
tartalmaz, a hash csak annyit mutat, hogy ugyanaz a kártékony fájl
érkezett meg. Másrészt a hash-érték csak annyira megbízható, amennyire a
forrása: ha a model_meta.json ugyanabban a nem védett
mappában van, mint a joblib-fájl, akkor egy támadó vagy hiba mindkettőt
kicserélheti. A megbízhatósági láncnak egy aláírt Git-commitig vagy
manifestig kell nyúlnia.
2. megoldás. A metaadat bővítése:
"output_schema": {
"kind": "predict_proba",
"classes_": [0, 1],
"shape": ["n_samples", 2],
"semantics": "second column is probability of target_late=1"
}A betöltő ellenőrzése a # 4. blokkban:
classes = model.classes_.tolist()
if classes != meta["output_schema"]["classes_"]:
raise ValueError(f"class mismatch: {classes}")
proba = model.predict_proba(control)
if proba.shape != (len(control), 2):
raise ValueError(f"unexpected predict_proba shape: {proba.shape}")
new_pred = proba[:, 1]A model.classes_ ellenőrzése azért fontos, mert egy
újonnan tanított modellnél a pozitív osztály sorrendje megváltozhat, és
a [:, 1] indexelés csendben rossz jelentést adna.
3. megoldás. A ColumnTransformer a
betanításkor rögzített feature-nevekre illeszt. Ha a betöltő
pd.DataFrame(loaded["controls"]) hívással nem ad
oszlopneveket, a scikit-learn 1.x verzióban ValueError vagy
explicit figyelmeztetés jelzi, hogy a feature-nevek nem egyeznek. A
prediction mismatch hiba ebből következik. Az a
veszélyesebb eset, ha a hiányos DataFrame oszlopai véletlenül a
tanításkori sorrendben vannak, és a predikció lefut: ekkor a kapu zöld
lehet, de a rendszer nem garantálja, hogy a jövőbeli bemenet is ezt a
sorrendet követi. Ezért a betöltőben az oszlopneveket expliciten, a séma
szerint kell beállítani.
4. megoldás. A kapcsoló bevezetése:
import sys
strict = "--strict-dependencies" in sys.argv[1:]
if strict:
for key, trained in meta["dependencies"].items():
current = {"python": sys.version.split()[0],
"numpy": np.__version__,
"pandas": pd.__version__,
"scikit-learn": sklearn.__version__,
"joblib": joblib.__version__}.get(key)
if current is not None and current != trained:
raise ValueError(f"dependency mismatch: {key} trained={trained} current={current}")
else:
print("WARN: dependency versions differ; prediction gate remains authoritative")Laza módban akkor engedhető át a modell, ha a predikciós eltérés kisebb egy üzletileg elfogadható küszöbnél, például -nál, és a verzióeltérést a futási naplóban, valamint a modellkártyán rögzítik. A példabeli eltérés ilyen küszöb alatt van, de ez üzleti döntés, nem technikai automatikus engedély.
Gyakori tévedés: „A saját gépen lévő joblib-fájl betöltése
biztonságos, mert mi hoztuk létre.” Ez nem igaz akkor, ha a fájl e-mail
mellékletből, megosztott mappából vagy CI-artifactként érkezik: a
deszerializálás tetszőleges kódvégrehajtásának lehetőségét hordozza.
Konkrét ellenpélda: egy támadó kicseréli a
pipeline.joblib-et olyan pickle-re, amely a deszerializálás
során fájlt ír vagy processzt indít. Ha a betöltő először
joblib.load-ot hív, a kár már a hash-ellenőrzés előtt
megtörténik. Az Adatverzió és splitmanifest
splitmanifestjénél már bevezetett hash-lánc itt visszatér: a hash csak
akkor véd, ha a metaadatot egy külön, megbízható csatornából, például
aláírt Git-commitból olvassuk. A Cache, backfill és
orchestrator cache-kulcsánál a hash célja az újrafelhasználás volt;
itt a cél a betöltési kapu. A LLM mint
sztochasztikus komponens külső LLM-értékelésénél hasonló bizalmi
határ jelentkezik, de ott nem deszerializálunk, hanem a szöveges
kimenetet értékeljük.
joblib.load előtt vagy
után van?A modellartefaktum három fájlból áll: a pipeline.joblib
a betanított objektum, a model_meta.json a séma, a
függőségi verziók és a fájlhashek szerződése, a
control_predictions.npz pedig a numerikus kapu. Betöltéskor
először a hash-ellenőrzés fut, csak utána a deszerializálás, majd a
séma- és kontrollpredikció-egyezés. A tolerancia
abszolút, regressziónál relatív taggal bővítve. Az ismeretlen pickle
mindig kódvégrehajtási kockázat. A következő Modelljelölt, jóváhagyás és registry ezt a kaput
registry-jóváhagyássá bővíti, ahol a modelljelölt verzióját és átadási
döntését is nyilvántartjuk.