A Northstar prediktív projektben a modell feladata rendeléskor
megbecsülni, hogy a kézbesítés meghaladja-e a 7 napot. A megfigyelési
egység egy rendelés; a célváltozó
target_late bináris. A modell bemenete a rendelés
pillanatában ismert nyolc feature: distance_km,
priority_flag, basket_minor,
quantity_sum, account_age_days,
segment, region,
acquisition_channel. A kimenet predict_proba
alakban annak valószínűsége, hogy a rendelés késik.
Ebben az alfejezetben nem új modellt tanítunk, hanem a predikciós szerződést építjük meg: azt a szabályt, amely garantálja, hogy a betanított pipeline tanításkor és későbbi új adaton pontosan ugyanazt a feature-sémát kapja. A Baseline és egyetlen teljes kísérlet alfejezetben elkészült a baseline kísérlet; most a modell artifacttá válik, és azt kérdezzük: mi történik, ha egy oszlop hiányzik, a kategóriák halmaza megváltozik, vagy az oszlopok sorrendje eltér?
Előzetes becslés: a konkrét valószínűség a betanított modelltől függ,
ezért nem írható elő. A szerződéses ellenőrzésnek viszont minden új
rekordra garantálnia kell, hogy predict_proba kimenete
(1, 2) alakú, az értékek 0 és 1 közé esnek, és soronként
1-re összegződnek.
A scikit-learn pipeline a betanítás során feature
names-ként rögzíti a bemeneti oszlopneveket. A
ColumnTransformer a nevek alapján választja ki a numerikus
és kategorikus oszlopokat; a kimeneti transzformált mátrix oszlopai
például num__distance_km, cat__region_north
alakú neveket kapnak. A sklearn 1.0 óta a pipeline
.feature_names_in_ attribútuma mutatja a fitkor látott
nyers neveket.
A dtype a változó típusa: distance_km
lebegőpontos (float64), a zászló és a darabszám egész
(int64), a szegmens, régió és csatorna szöveges
(object vagy category). A típus nem üres
formalitás: ha egy oszlop például string formátumból
int64-re konvertálódik, a ColumnTransformer
numerikus transzformert hívhat rá, és a modell értelme megváltozik.
A missing columns ellenőrzés azt jelenti, hogy a
szerződésben előírt minden oszlopnak jelen kell lennie. A hiányzó oszlop
a ColumnTransformer.transform() hívásakor
KeyError-t ad, de a hibaszöveg nem mindig mondja meg,
melyik oszlop hiányzik. A saját validátor ezt karbantartható üzenetté
alakítja.
A schema version egy rövid, ember által is olvasható
azonosító, például northstar-order-v1. A modell artifact
mellé mentjük. Ha a feature-definíció változik — például új oszlop
jelenik meg, vagy a basket_minor egysége centről euróra
vált —, a verziót növelni kell, különben a régi modell némán rossz
bemenetet kaphat.
A predict_proba bináris osztályozásnál két oszlopot ad
vissza. Az első a 0 osztály, a második az 1 osztály valószínűsége; a
sorrendet a modell .classes_ attribútuma rögzíti. A késés
valószínűsége tehát:
ahol a logisztikus függvény, a preprocessing-utáni feature-vektor, a súlyvektor, a tengelymetszet. Minden sorra fennáll:
Ez utóbbi a kézi kontroll alapja.
A nyers bemenet mátrix, ahol a rendelések száma, a feature-ek száma. A preprocessing leképezést hajt végre. A példában a numerikus oszlopok száma 5, a kategorikus one-hot kódolás után a szegmens 2, a régió 3, a csatorna 3 dummy oszlopot ad, így .
A dimenzióellenőrzés tehát két ponton érvényesül:
ColumnTransformer kimenete minden rekordra
dimenziós vektor.LogisticRegression súlyvektora
,
tehát a coef_ shape (1, 13).Ha egy oszlop hiányzik, akkor a numerikus blokk kimenete például 4 dimenziós lesz, a teljes transzformált vektor pedig csak 12 dimenziós. A modell ekkor dimenzióhibát ad, mert a súlyvektor hossza nem egyezik a bemenettel.
A változójelentés-ellenőrzés a mértékegységekre és a szemantikára
vonatkozik: distance_km kilométer,
basket_minor EUR-cent, account_age_days nap,
priority_flag 0/1 indikátor. A szerződés ezt a jelentést
dokumentálja, még ha a dtype önmagában nem is hordozza. A scikit-learn
nem tudja ellenőrizni, hogy egy float64 oszlop kilométer
vagy mérföld; ezt a schema-leírásnak kell rögzítenie.
A következő kód a lab projektgyökérből futtatható. Betölti a tiszta
adatokat, kiszámítja az account_age_days feature-t, a
2025-05-01 cutoff előtti, már megérkezett címkéjű rendelésekre tanít egy
pipeline-t, majd a pipeline-t és a szerződést egyetlen joblib artifactba
menti. A kódot itt nem futtattuk; a kimenetek a helyi gépen a seedtől és
a csomagverzióktól függően alakulnak, de a validátor szerkezete
determinisztikus.
# scripts/predict_contract.py -- futtatás a lab projektgyökérből:
# python scripts/predict_contract.py
from pathlib import Path
import warnings
import joblib
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.linear_model import LogisticRegression
DATA = Path("data")
ARTIFACTS = Path("artifacts")
ARTIFACTS.mkdir(exist_ok=True)
orders = pd.read_csv(
DATA / "orders.csv",
parse_dates=["order_ts", "actual_delivery_at", "label_available_at"],
)
customers = pd.read_csv(DATA / "customers.csv")
df = orders.merge(customers[["customer_id", "signup_date"]], on="customer_id", how="left")
df["account_age_days"] = (df["order_ts"] - pd.to_datetime(df["signup_date"])).dt.days
FEATURES = [
"distance_km", "priority_flag", "basket_minor", "quantity_sum",
"account_age_days", "segment", "region", "acquisition_channel",
]
TARGET = "target_late"
cutoff = pd.Timestamp("2025-05-01T00:00:00+00:00")
train = df[(df["order_ts"] < cutoff) & (df["label_available_at"] < cutoff)]
X = train[FEATURES]
y = train[TARGET].astype(int)
numeric_features = [
"distance_km", "priority_flag", "basket_minor", "quantity_sum", "account_age_days"
]
categorical_features = ["segment", "region", "acquisition_channel"]
preprocessor = ColumnTransformer(
transformers=[
("num", StandardScaler(), numeric_features),
("cat", OneHotEncoder(handle_unknown="ignore"), categorical_features),
]
)
pipeline = Pipeline([
("prep", preprocessor),
("model", LogisticRegression(max_iter=1000, random_state=42)),
])
pipeline.fit(X, y)
SCHEMA_CONTRACT = {
"schema_version": "northstar-order-v1",
"features": {
"distance_km": {"dtype": "float64"},
"priority_flag": {"dtype": "int64"},
"basket_minor": {"dtype": "int64"},
"quantity_sum": {"dtype": "int64"},
"account_age_days": {"dtype": "int64"},
"segment": {"dtype": "object", "categories": ["consumer", "business"]},
"region": {"dtype": "object", "categories": ["north", "central", "south"]},
"acquisition_channel": {
"dtype": "object",
"categories": ["search", "referral", "direct"],
},
},
}
artifact = {
"schema_version": SCHEMA_CONTRACT["schema_version"],
"contract": SCHEMA_CONTRACT,
"pipeline": pipeline,
}
joblib.dump(artifact, ARTIFACTS / "northstar_order_predictor.joblib")
print("artifact saved:", ARTIFACTS / "northstar_order_predictor.joblib")A betöltés utáni validátor és újrekordos predikció:
class SchemaError(ValueError):
pass
def validate_schema(frame: pd.DataFrame, contract: dict) -> pd.DataFrame:
expected = list(contract["features"].keys())
actual = list(frame.columns)
if actual != expected:
missing = set(expected) - set(actual)
extra = set(actual) - set(expected)
raise SchemaError(
f"Column order mismatch; missing={sorted(missing)}, extra={sorted(extra)}"
)
for name, spec in contract["features"].items():
series = frame[name]
if str(series.dtype) != spec["dtype"]:
raise SchemaError(
f"{name}: expected dtype {spec['dtype']}, got {series.dtype}"
)
if series.isnull().any():
raise SchemaError(f"{name}: missing values are not allowed")
if "categories" in spec:
unknown = set(series.dropna().unique()) - set(spec["categories"])
if unknown:
warnings.warn(
f"{name}: unknown categories {sorted(unknown)}; "
"the model will see an all-zero one-hot row.",
RuntimeWarning,
)
return frame
def predict_new_order(artifact_path: Path, features: dict) -> dict:
bundle = joblib.load(artifact_path)
contract = bundle["contract"]
pipeline = bundle["pipeline"]
row = pd.DataFrame([features])[list(contract["features"].keys())]
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
validate_schema(row, contract)
proba = pipeline.predict_proba(row)
classes = list(pipeline.named_steps["model"].classes_)
late_index = classes.index(1)
return {
"probability_late": float(proba[0, late_index]),
"probability_vector": proba[0].tolist(),
"classes": classes,
"warnings": [str(w.message) for w in caught if w.category is RuntimeWarning],
}
# Egy új rendelés rekord, a szerződés szerinti oszlopsorrendben.
new_record = {
"distance_km": 12.0,
"priority_flag": 1,
"basket_minor": 4500,
"quantity_sum": 3,
"account_age_days": 120,
"segment": "consumer",
"region": "north",
"acquisition_channel": "search",
}
result = predict_new_order(
ARTIFACTS / "northstar_order_predictor.joblib", new_record
)
print(result)A szemléltető kimenet szerkezete:
{
'probability_late': 0.21, # a konkrét érték a betanított modelltől függ
'probability_vector': [0.79, 0.21],
'classes': [0, 1],
'warnings': []
}A három hibateszt:
# 1. Hiányzó oszlop: regiónak jelen kell lennie.
missing_column = {k: v for k, v in new_record.items() if k != "region"}
try:
predict_new_order(ARTIFACTS / "northstar_order_predictor.joblib", missing_column)
except SchemaError as e:
print("missing column error:", e)
# 2. Ismeretlen kategória: a modell nem tör el, de figyelmeztetést ad.
unknown_category = {**new_record, "region": "west"}
res = predict_new_order(ARTIFACTS / "northstar_order_predictor.joblib", unknown_category)
print("unknown category warnings:", res["warnings"])
# 3. Elcserélt oszlopsorrend: a szerződés a sorrendet is rögzíti.
swapped = {**new_record}
swapped["region"], swapped["distance_km"] = swapped["distance_km"], swapped["region"]
try:
predict_new_order(ARTIFACTS / "northstar_order_predictor.joblib", swapped)
except SchemaError as e:
print("order error:", e)Futtatási parancs a projektgyökérből, a
lab/pyproject.toml alapfüggőségeivel:
# Munka katalógus: lab/
uv run python scripts/predict_contract.pyA kézi kontroll nem a modell pontosságát, hanem a szerződés teljesülését ellenőrzi:
predict_proba kimenete egyetlen új rekordra
(1, 2) alakú.probability_vector összege 1.0; ennek
toleranciája a lebegőpontos ábrázolás miatt legfeljebb
lehet.classes lista [0, 1], tehát a
probability_late a második oszlop értéke.SchemaError üzenete megnevezi a
hiányzó oszlopot.warnings lista tartalmazza, hogy a régió all-zero one-hot
sort kapott.A dimenzióinvariáns: a pipeline prep lépése a nyolc
bemeneti oszlopból 13 transzformált oszlopot állít elő; a
model.coef_ hossza szintén 13. Ezt a tanuló a futtatás után
így ellenőrizheti:
assert pipeline.named_steps["model"].coef_.shape[1] == 13
assert len(pipeline.named_steps["prep"].get_feature_names_out()) == 13A bemeneti séma ellenőrzése ne csak az oszlopneveket vizsgálja: típus, hiányzóérték-szabály, mértékegység, kategorikus ismeretlen értékek és időbeli elérhetőség is része az adatkontraktusnak.
Értelmezés. Magyarázza el, miért nem elegendő a szerződésben csak az oszlopnevek meglétét ellenőrizni, és miért kell a sorrendet is rögzíteni. Adjunk példát arra, hogy a nevek megléte ellenére hogyan kaphat a modell téves feature-t, ha a fogyasztó nem a nevekre, hanem a pozícióra épít.
Implementálás. Írja meg a
validate_schema függvényt úgy, hogy a
SchemaError üzenete egyszerre sorolja fel a hiányzó és a
felesleges oszlopokat. Futtassa a három hibatesztet, és győződjön meg
arról, hogy az ismeretlen kategória valóban
RuntimeWarning-ot ad, nem pedig kivételt.
Hibakeresés. A csapat egyik tagja kihagyta az
account_age_days oszlopot a betöltéskor. A pipeline először
ezt a hibát adja:
X has 7 features, but StandardScaler is expecting 8 features as input.
Mi a hiba gyökere, és miért nem elég a hibaüzenet a diagnózishoz? Hogyan
segíti a validátor a pontos azonosítást?
Megváltozott feltétel. Tegyük fel, hogy az üzlet
új régiót vezet be (west). A modell a
handle_unknown="ignore" miatt nem tör el, de a predikció
all-zero one-hot sort kap. Módosítsa a predict_new_order
függvényt úgy, hogy ilyenkor a visszaadott dict kapjon egy
"confidence": "low" mezőt. Indokolja meg, miért csak
figyelmeztetés, és miért nem helyes automatikusan elutasítani a
rekordot.
A DataFrame oszlopnevei a scikit-learn számára a szemantikát
hordozzák, de a környező rendszerekben az adat gyakran pozíciós
listaként érkezik. Ha a fogyasztó X.to_numpy()-t ad át,
akkor a modell már nem látja a neveket, és a betanított súlyok a tömb
oszlopindexeire vonatkoznak. Ha egy CSV-exportáló eszköz az oszlopokat
más sorrendben írja ki, a to_numpy() által adott mátrix
első oszlopa nem feltétlenül distance_km, hanem például
region, és a modell a régió dummy oszlopait próbálja
kilométerként értelmezni. A sorrend rögzítése a szerződésben
megakadályozza, hogy egy downstream folyamat pozícióra építsen.
A validate_schema fenti implementációja a
actual != expected összehasonlítással egyszerre ellenőrzi a
sorrendet és a hiányzó/felesleges oszlopokat. A tesztfuttatás során a
hiányzó oszlop esetén SchemaError keletkezik
missing=['region'] üzenettel; az ismeretlen kategória
esetén a kimenet warnings listája tartalmazza a
figyelmeztetést, a probability_vector pedig továbbra is két
elemből áll; az elcserélt sorrend esetén a hibaüzenet
Column order mismatch szöveggel indul. Ellenőrző invariant:
a szerződésben szereplő list(contract["features"].keys())
minden hívásnál ugyanazt a sorrendet adja.
A hibaüzenet a belső StandardScaler dimenziójára
utal, de a gyökér az, hogy a ColumnTransformer a hiányzó
account_age_days nélkül csak 4 numerikus oszlopot kapott,
ezért a numerikus blokk kimenete 7 oszlop lett a várt 8 helyett. A
validátor már a pipeline hívása előtt megmondja, hogy melyik oszlop
hiányzik; a SchemaError üzenete így közvetlenül a
missing={'account_age_days'} listát adja. A modell belső
dimenzióhibája tehát a séma eltérés tünete, nem a diagnózis
forrása.
A módosított függvény:
def predict_new_order(artifact_path: Path, features: dict) -> dict:
bundle = joblib.load(artifact_path)
contract = bundle["contract"]
pipeline = bundle["pipeline"]
row = pd.DataFrame([features])[list(contract["features"].keys())]
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
validate_schema(row, contract)
proba = pipeline.predict_proba(row)
classes = list(pipeline.named_steps["model"].classes_)
late_index = classes.index(1)
warnings_text = [str(w.message) for w in caught if w.category is RuntimeWarning]
return {
"probability_late": float(proba[0, late_index]),
"probability_vector": proba[0].tolist(),
"classes": classes,
"warnings": warnings_text,
"confidence": "low" if any("unknown categories" in w for w in warnings_text) else "normal",
}Az automatikus elutasítás azért lenne hibás, mert az ismeretlen
kategória nem feltétlenül jelenti a rekord érvénytelenségét: a
handle_unknown="ignore" a modell explicit szerződése arra,
hogy ismeretlen kategóriánál nullákkal kódol. A "low"
bizalomjelzés viszont figyelmezteti a döntéshozót, hogy a modell
ilyenkor kevésbé megbízható. Ha az új kategória gyakorivá válik, a séma
verzióját növelni és a modellt újra kell tanítani.
A leggyakoribb tévedés, hogy a DataFrame-oszlopok sorrendje garantált
a fájltípus miatt. A CSV és a Parquet egyaránt oszlopneveket tárol, de a
fájlba íráskor az oszlopsorrend a kiíró könyvtártól függ. Ugyanaz a
logikai tábla érkezhet [distance_km, priority_flag, ...] és
[priority_flag, distance_km, ...] sorrendben is. A
scikit-learn a DataFrame nevei alapján dolgozik, ezért a pipeline
közvetlen hívása mindkét sorrendnél ugyanazt az eredményt adja,
de ha valaki a DataFrame-et
to_numpy()-ként adja tovább, a nevek eltűnnek. A sklearn
1.2 óta ilyenkor figyelmeztet, hogy a transzformer feature-neveket vár,
de a modell mégis a tömb pozícióit használja. A predikciós szerződés
ezért a nevek, a dtype-ok és a sorrend egységét ellenőrzi.
Visszautalás: a Baseline és egyetlen teljes
kísérlet baseline kísérletében a pipeline a tanító adaton belül
tanulta a transzformációt; a GLM: bináris és
számlálási cél logisztikus modellben a predict_proba a
sigmoid függvényből származott; a Stabil numerika és
érzékenység stabil numerika pedig arra emlékeztet, hogy a
valószínűségek összegzésekor a lebegőpontos kerekítés toleranciát
igényel. Önellenőrzés: tartalmazza-e a szerződés a séma verzióját, a
feature-neveket, a dtype-okat és a kategóriák halmazát? Hibát ad-e
hiányzó oszlopra? Figyelmeztet-e ismeretlen kategóriánál anélkül, hogy
leállna? A predict_proba sorösszege 1-e?
A predikciós szerződés a betanított pipeline és az új adat közötti
stabilitást garantálja. A szerződés rögzíti a séma verzióját, a
feature-neveket, a dtype-okat és a kategorikus változók megengedett
értékeit. A hiányzó oszlopra és az elcserélt sorrendre a validátor
pontos hibát ad; az ismeretlen kategóriát a modell a
handle_unknown="ignore" beállítás miatt nullákkal kódolja,
de a validátor alacsony bizalomjelzést ad. A predict_proba
kimenete mindig (1, 2) alakú, és soronként 1-re
összegződik. Következő alkalmazás: a Lineáris és
logisztikus prediktív modellek lineáris és logisztikus prediktív
modellek, ahol ugyanezt a szerződést használjuk a modellcsaládok
összehasonlításához.