31.3 Modellartefaktum és betöltési határ

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.

31.3.1 Helyzet: egy pipeline két processzben

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 10−1510^{-15}–10−1410^{-14} nagyságrendű, tehát τabs=10−9\tau_{\mathrm{abs}} = 10^{-9} konzervatív kaput ad.

31.3.2 Model signature: a be- és kimenet szerződése

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.

31.3.3 Dependency version: amit a modell feltételez

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:

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.

31.3.4 joblib/pickle trust: miért nem egy lépés a betöltés?

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:

  1. a fájl SHA-256 hashének összehasonlítása a megbízható metaadattal;
  2. csak ezután joblib.load;
  3. majd séma- és kontrollpredikció-ellenőrzés.

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.

31.3.5 Format: három fájl három szerepben

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.

31.3.6 Load check: a numerikus tolerancia kapuja

A betöltő processz a kontrollsorokra újra kiszámítja a predikciókat, és a mentett értékekkel hasonlítja össze. Az ii-edik kontrollsorra a feltétel:

maxi=1,…,m|pinew−pistored|≤τabs, \max_{i=1,\dots,m} \left| p_i^{\text{new}} - p_i^{\text{stored}} \right| \le \tau_{\mathrm{abs}},

ahol mm a kontrollsorok száma, pistoredp_i^{\text{stored}} a mentéskori modellkimenet, pinewp_i^{\text{new}} a betöltés utáni kimenet, τabs\tau_{\mathrm{abs}} pedig az abszolút tolerancia. A példában pip_i a pozitív osztály valószínűsége, tehát mértékegysége 1, tartománya [0,1][0,1], 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:

|pinew−pistored|≤τabs+τrel|pistored|, | p_i^{\text{new}} - p_i^{\text{stored}} | \le \tau_{\mathrm{abs}} + \tau_{\mathrm{rel}} | p_i^{\text{stored}} |,

ahol τrel\tau_{\mathrm{rel}} relatív tolerancia, tipikusan 10−610^{-6}. Kézi kontroll: ha pstored=[0.2,0.8]p^{\text{stored}} = [0.2, 0.8] és pnew=[0.1999999999,0.8000000001]p^{\text{new}} = [0.1999999999, 0.8000000001], akkor a legnagyobb eltérés 10−1010^{-10}, ami τabs=10−9\tau_{\mathrm{abs}} = 10^{-9} alatt van, tehát a kapu nyit.

31.3.7 Teljes minimális munkapélda

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_v1

Windows PowerShellből:

python check_model_artifact.py local_models\rendeles_keses_v1

Szemlé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 10−1610^{-16}–10−1510^{-15} nagyságrendű, de ez nem garantált seedek és platformok között.

31.3.8 Eredmény értelmezése

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.

31.3.9 Feladatok

  1. É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?

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

  3. 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?

  4. 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 2⋅10−82 \cdot 10^{-8}, ami a 10−910^{-9} 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?

31.3.10 Megoldások és indoklás

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 10−610^{-6}-nál, és a verzióeltérést a futási naplóban, valamint a modellkártyán rögzítik. A példabeli 2⋅10−82 \cdot 10^{-8} eltérés ilyen küszöb alatt van, de ez üzleti döntés, nem technikai automatikus engedély.

31.3.11 Gyakori tévedés ellenpéldával

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.

31.3.12 Önellenőrzés

31.3.13 Rövid összefoglaló

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 10−910^{-9} 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.