A Modulhatár és tiszta függvény alfejezetben a notebook logikáját tiszta függvényekre és parancsokra bontottad. Most azt a kérdést oldjuk meg, hogyan marad egy kutatási futtatás később is visszakereshető: milyen konfigurációval, milyen adaton, milyen környezetben és milyen eredménnyel futott. A konkrét helyzet egy Northstar-stílusú, de kicsi, szintetikus predikciós feladat: a rendelés pillanatában ismert három feature-ből becsüljük, hogy a kézbesítés 7 napon túli lesz-e.
configs/tiny.toml,
data/tiny_train.csv, data/tiny_test.csv.row_id azonosítóval.target_late 0/1.distance_km,
priority_flag, basket_minor.manifest.json, amely hash-ekkel rögzíti a bemeneteket.DummyClassifier
ROC-AUC-ja 0,50 körül, a logisztikus regresszió ROC-AUC-ja szemléltető
jelleggel 0,70–0,80 közé várható. A pontos számot a helyi futtatás dönti
el.TOML vagy YAML? A TOML a Python 3.11-től beépített
tomllib modullal olvasható, típusos értékeket ad (string,
egész, float, boolean, tömb, tábla), és nem enged tetszőleges
Python-objektumot végrehajtani. A YAML kényelmes, de külön
PyYAML függőséget és biztonsági megfontolást igényel. Ebben
az alfejezetben TOML-t használunk; ha egy csapat már YAML-t használ, a
döntés lehet más, de a manifest-hash akkor is kötelező marad.
argparse. Az argparse csak a folyamat bemeneti
határa: szöveges argumentumokat olvas, és egy Namespace
objektumot ad vissza. A modell-, adat- és kiíró logika nem az
argparse-on belül él. A belépési pont így:
def parse_args(argv=None) -> argparse.Namespace:
...Log levels. A print nem futásjegyzék,
mert nem szűrhető és nem íródik fájlba konzisztensen. A logging szintek
a fontosságot jelölik:
| Szint | Tipikus üzenet | Ne ide kerüljön |
|---|---|---|
| DEBUG | shape, kulcsérték, részletes állapot | normál összefoglaló |
| INFO | lépések, modellmetrika, fájlok | minden ciklusváltozó |
| WARNING | gyanús, de folytatható esemény | kezelhetetlen hiba |
| ERROR | sikertelen művelet | szokásos futási zaj |
Run id. A futás azonosítója UTC-időbélyegből és
rövid véletlen utótagból áll, például
20261020T093001Z-7f3a. Az időbélyeg önmagában nem elég
párhuzamos futtatáskor; a véletlen utótag csökkenti az ütközés
valószínűségét, a mkdir(exist_ok=False) pedig egy ütközést
észlelhető hibává alakít; retry vagy új azonosító szükséges, nem
feltételezhető feltétlen ütközésmentesség.
Manifest. A manifest a futás géppel ellenőrizhető jegyzőkönyve: parancssor, konfiguráció- és adathash, környezetverziók és modellmetrikák. A hash itt SHA-256, hexadecimális formában; bármilyen byte-eltérés más hash-t ad.
Immutable output. A futási könyvtár írásvédett
logikájú: egyszer hozzuk létre, a fájlokat ideiglenes néven írjuk, majd
os.replace-szel tesszük láthatóvá. Így egy fájl vagy teljes
egészében megjelenik, vagy sehogy; utólagos szerkesztés érvényteleníti a
manifest ellenőrző szerepét.
A projekt szerkezete a Modulhatár és tiszta
függvény src layoutját követi:
s114_demo/
├── pyproject.toml
├── configs/
│ └── tiny.toml
├── scripts/
│ └── make_tiny_data.py
├── data/ # a generátor tölti fel
└── src/
└── northstar/
├── __init__.py
├── config.py
└── run.py
A pyproject.toml minimális változata:
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "northstar-s114"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = ["pandas>=2.2", "scikit-learn>=1.4"]
[tool.setuptools.packages.find]
where = ["src"]A konfiguráció:
[run]
models = ["dummy", "logreg"]
seed = 20261020
[data]
train_csv = "data/tiny_train.csv"
test_csv = "data/tiny_test.csv"
target_col = "target_late"
[model.logreg]
C = 0.5
max_iter = 500Az adatgenerátor a korábbi Lineáris és logisztikus prediktív modellek alfejezetből ismert logit-függvénnyel sorsol 600 sort. Minden sorra:
ahol
a distance_km (km),
a basket_minor (EUR-cent),
a priority_flag;
,
,
,
.
A target_late 1, ha a véletlen szám kisebb
-nél.
# scripts/make_tiny_data.py
import csv
import math
import random
from pathlib import Path
RNG_SEED = 7
N_TRAIN, N_TEST = 400, 200
OUT_DIR = Path("data")
BETA = (-2.0, 0.06, 0.0001, 0.8)
def rows(n, start_id, rng):
out = []
for i in range(n):
row_id = start_id + i
distance = round(rng.uniform(0.5, 30.0), 2)
priority = rng.randint(0, 1)
basket = rng.randint(500, 12000)
z = BETA[0] + BETA[1] * distance + BETA[2] * basket + BETA[3] * priority
p = 1.0 / (1.0 + math.exp(-z))
out.append({
"row_id": row_id,
"distance_km": distance,
"priority_flag": priority,
"basket_minor": basket,
"target_late": 1 if rng.random() < p else 0,
})
return out
def write_csv(path, data):
with path.open("w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=list(data[0]))
writer.writeheader()
writer.writerows(data)
rng = random.Random(RNG_SEED)
all_rows = rows(N_TRAIN + N_TEST, 1, rng)
rng.shuffle(all_rows)
OUT_DIR.mkdir(exist_ok=True)
write_csv(OUT_DIR / "tiny_train.csv", all_rows[:N_TRAIN])
write_csv(OUT_DIR / "tiny_test.csv", all_rows[N_TRAIN:])
print(OUT_DIR / "tiny_train.csv", OUT_DIR / "tiny_test.csv")A konfiguráció és hash-segéd:
# src/northstar/config.py
import hashlib
import tomllib
from pathlib import Path
from typing import Any
def load_toml(path: str | Path) -> dict[str, Any]:
with open(path, "rb") as f:
return tomllib.load(f)
def sha256_file(path: str | Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()A belépési pont összefogja az argparse-ot, a loggingot, a run id-t, a két modell tanítását, a predikciók atomi kiírását és a manifestet:
# src/northstar/run.py
from __future__ import annotations
import argparse
import json
import logging
import os
import platform
import secrets
import sys
import time
from datetime import datetime, timezone
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
from typing import Any
import pandas as pd
from sklearn.dummy import DummyClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, roc_auc_score
from northstar.config import load_toml, sha256_file
logger = logging.getLogger("northstar")
FEATURES = ["distance_km", "priority_flag", "basket_minor"]
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
p = argparse.ArgumentParser(description="Northstar s114 futtatás")
p.add_argument("--config", required=True)
p.add_argument("--output-root", default="runs")
p.add_argument("--run-id", default=None)
p.add_argument("--tag", default=None)
p.add_argument("--log-level", default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR"])
return p.parse_args(argv)
def make_run_id(tag: str | None) -> str:
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
run_id = f"{stamp}-{secrets.token_hex(2)}"
return f"{tag.replace(' ', '_')}-{run_id}" if tag else run_id
def setup_logging(level_name: str, run_dir: Path) -> None:
formatter = logging.Formatter(
"%(asctime)s %(levelname)s %(name)s %(message)s",
datefmt="%Y-%m-%dT%H:%M:%SZ",
)
formatter.converter = time.gmtime
root = logging.getLogger()
root.setLevel(getattr(logging, level_name.upper()))
for handler in list(root.handlers):
root.removeHandler(handler)
fh = logging.FileHandler(run_dir / "run.log", encoding="utf-8")
fh.setFormatter(formatter)
sh = logging.StreamHandler()
sh.setFormatter(formatter)
root.addHandler(fh)
root.addHandler(sh)
def atomic_write_str(path: Path, content: str) -> None:
tmp = path.parent / f".{path.name}.{secrets.token_hex(2)}.tmp"
try:
tmp.write_text(content, encoding="utf-8")
os.replace(tmp, path)
except Exception:
tmp.unlink(missing_ok=True)
raise
def atomic_write_df(path: Path, df: pd.DataFrame) -> None:
tmp = path.parent / f".{path.name}.{secrets.token_hex(2)}.tmp"
try:
df.to_csv(tmp, index=False)
os.replace(tmp, path)
except Exception:
tmp.unlink(missing_ok=True)
raise
def train_one(name: str, cfg: dict[str, Any], train: pd.DataFrame,
test: pd.DataFrame, seed: int):
target = cfg["data"]["target_col"]
X_train, y_train = train[FEATURES], train[target]
X_test, y_test = test[FEATURES], test[target]
if name == "dummy":
model = DummyClassifier(strategy="prior", random_state=seed)
elif name == "logreg":
p = cfg["model"]["logreg"]
model = LogisticRegression(
C=p["C"], max_iter=p["max_iter"], random_state=seed
)
else:
raise KeyError(f"ismeretlen modell: {name}")
model.fit(X_train, y_train)
proba = model.predict_proba(X_test)[:, 1]
pred = (proba >= 0.5).astype(int)
metrics = {
"accuracy": round(float(accuracy_score(y_test, pred)), 6),
"roc_auc": round(float(roc_auc_score(y_test, proba)), 6),
"n_test": int(len(y_test)),
"positive_rate_test": round(float(y_test.mean()), 6),
"pred_positive_rate": round(float(pred.mean()), 6),
}
return model, proba, pred, metrics
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
cfg_path = Path(args.config).resolve()
try:
cfg = load_toml(cfg_path)
except Exception:
logger.exception("config betöltés sikertelen")
return 2
output_root = Path(args.output_root)
output_root.mkdir(parents=True, exist_ok=True)
run_id = args.run_id or make_run_id(args.tag)
run_dir = output_root / run_id
try:
run_dir.mkdir(parents=False, exist_ok=False)
except FileExistsError:
logger.error("run könyvtár már létezik: %s", run_dir)
return 3
setup_logging(args.log_level, run_dir)
logger.info("config=%s run_id=%s output_root=%s",
cfg_path, run_id, output_root)
train_path = cfg_path.parent / cfg["data"]["train_csv"]
test_path = cfg_path.parent / cfg["data"]["test_csv"]
try:
train = pd.read_csv(train_path)
test = pd.read_csv(test_path)
except Exception:
logger.exception("adat betöltés sikertelen")
return 4
logger.info("train shape=%s test shape=%s", train.shape, test.shape)
atomic_write_str(run_dir / "config.toml",
cfg_path.read_text(encoding="utf-8"))
versions = {}
for pkg in ("northstar-s114", "pandas", "scikit-learn"):
try:
versions[pkg] = version(pkg)
except PackageNotFoundError:
versions[pkg] = "not-installed"
results: dict[str, dict[str, float]] = {}
seed = int(cfg["run"]["seed"])
for name in cfg["run"]["models"]:
try:
_, proba, pred, metrics = train_one(name, cfg, train, test, seed)
except Exception:
logger.exception("model failed=%s", name)
return 5
results[name] = metrics
logger.info("model=%s accuracy=%s roc_auc=%s",
name, metrics["accuracy"], metrics["roc_auc"])
out = test[["row_id"]].copy()
out["prob_late"] = proba
out["pred_late"] = pred
atomic_write_df(run_dir / f"predictions_{name}.csv", out)
manifest = {
"run_id": run_id,
"created_at_utc": datetime.now(timezone.utc).isoformat(),
"command_line": " ".join(sys.argv),
"config": {
"path": str(cfg_path),
"copy": "config.toml",
"sha256": sha256_file(cfg_path),
},
"data": {
"train": {"path": str(train_path), "sha256": sha256_file(train_path)},
"test": {"path": str(test_path), "sha256": sha256_file(test_path)},
},
"environment": {
"python_version": platform.python_version(),
"platform": platform.platform(),
"package_versions": versions,
},
"models": results,
}
atomic_write_str(run_dir / "manifest.json",
json.dumps(manifest, indent=2, sort_keys=True) + "\n")
logger.info("manifest írva; run kész")
return 0
if __name__ == "__main__":
raise SystemExit(main())Futtatás Linux/macOS bash-ban, a s114_demo
munkakönyvtárból:
cd s114_demo
python -m venv .venv
source .venv/bin/activate
pip install -e .
python scripts/make_tiny_data.py
python -m northstar.run --config configs/tiny.toml \
--output-root runs --log-level INFOPowerShell-ben az aktiválás és a perjel eltér:
cd s114_demo
python -m venv .venv
.venv\Scripts\Activate.ps1
pip install -e .
python scripts\make_tiny_data.py
python -m northstar.run --config configs\tiny.toml --output-root runs --log-level INFOSzemléltető, nem automatikusan futtatott kimenet; a helyi futtatás után ellenőrizd a tényleges számokat:
2026-10-20T09:30:01Z INFO northstar config=/s114_demo/configs/tiny.toml run_id=20261020T093001Z-7f3a output_root=runs
2026-10-20T09:30:02Z INFO northstar train shape=(400, 5) test shape=(200, 5)
2026-10-20T09:30:03Z INFO northstar model=dummy accuracy=0.4850 roc_auc=0.5000
2026-10-20T09:30:04Z INFO northstar model=logreg accuracy=0.7100 roc_auc=0.7598
2026-10-20T09:30:04Z INFO northstar manifest írva; run kész
A futás után a runs/20261020T093001Z-7f3a/ könyvtár
tartalma:
config.toml
manifest.json
predictions_dummy.csv
predictions_logreg.csv
run.log
A dummy modell a tanító pozitív arányt konstansként jósolja. A teszt-accuracy kézi kontrollja:
ahol a tesztcímke átlaga. Ha például és , akkor ; a konkrét fájlban a saját arányokkal ellenőrizhető. A dummy ROC-AUC konstans valószínűségnél 0,50. A logreg ROC-AUC ennél magasabb kell legyen; ha nem, hiba gyanús.
A shape-invariáns: mindkét predictions_*.csv sorszáma
200, tehát egyezik a teszt shape[0] értékével;
pred_late pontosan (prob_late >= 0.5).
A konfiguráció hash-ellenőrzése Pythonból:
from pathlib import Path
from northstar.config import sha256_file
print(sha256_file("configs/tiny.toml"))A kapott értéknek egyeznie kell a manifest config.sha256
mezőjével.
Értelmezés. A manifestben ez áll:
"models": {"dummy": {"roc_auc": 0.5, "positive_rate_test": 0.49, "pred_positive_rate": 0.4675}}.
Miért nem egyezik a dummy pred_positive_rate a teszt
pozitív arányával, és miért éppen 0,4675 lehet ez az érték? Mi
következik a dummy ROC-AUC 0,5 értékéből?
Megvalósítás. Egészítsd ki a
run.py-t úgy, hogy a modellmetrikákat külön
metrics.json fájlba is kiírja a futási könyvtárba, és a
manifestbe bekerüljön a metrics.sha256 mező. A
metrics.json tartalma legyen
{"models": results, "seed": seed}. A kiírást atomi
művelettel végezd.
Hibadiagnózis. A társad kétszer futtatja
ugyanazt a parancsot, másodszor
--run-id 20260101T000000Z-0000 értékkel, miután az első
futás már létrehozta ezt a könyvtárat. Mit ír ki a program, milyen
visszatérési kóddal áll meg, és miért nem írja felül a régi manifestet?
Hogyan kellene helyesen újra futtatni?
Megváltozott feltétel. A
make_tiny_data.py RNG_SEED értékét 7-ről 8-ra
változtattad, majd újra legeneráltad a CSV-ket, és újra futtattad a
modellt. A modell ROC-AUC-ja romlott. A manifest melyik mezője
bizonyítja, hogy a bemeneti adat változott? Milyen további teendő kell
ahhoz, hogy a régi és az új futást összehasonlíthatóan tárold?
A DummyClassifier(strategy="prior") a
tanító halmaz pozitív arányát tanulja meg, és minden
teszt-sorra ugyanazt a
-t
adja. Ezért a teszt-predikciók átlaga a tanító-arány, 0,4675, ami
általában nem egyezik a teszt saját pozitív arányával, 0,49-del. A
konstans valószínűség nem rangsorolja a tesztpéldányokat, ezért a
ROC-AUC elvárt értéke 0,5; ez nem modellhiba, hanem a baseline
értelme.
Egy lehetséges megoldás a manifest írása előtt:
metrics_path = run_dir / "metrics.json"
metrics_text = json.dumps(
{"models": results, "seed": seed}, indent=2, sort_keys=True
) + "\n"
atomic_write_str(metrics_path, metrics_text)
manifest["metrics"] = {
"path": "metrics.json",
"sha256": sha256_file(metrics_path),
}Az ellenőrző invariáns:
sha256_file(run_dir / "metrics.json") egyezik a manifest
metrics.sha256 értékével, és a fájl egyetlen JSON-objektum,
amelynek models kulcs alatt pontosan a két modell neve
szerepel.
A második futtatás a run_dir.mkdir(exist_ok=False)
hívásnál FileExistsError kivételt kap, a program
ERROR northstar run könyvtár már létezik: ... üzenetet ír,
és 3-as kóddal tér vissza. Nem írja felül a régi manifestet, mert az
immutable output szerződés tiltja a futási könyvtár újraírását. Helyes
megoldás új --output-root vagy új --run-id; ha
a régi futás hibás, a teljes runs/<régi-id>
könyvtárat kézzel törölni, majd új futást indítani.
A manifest data.train.sha256 és
data.test.sha256 mezője megváltozik, mert a CSV
byte-tartalma más. A régi és új futás akkor marad összehasonlítható, ha
mindkét runs/<run_id> könyvtárat megőrzöd: mindkettő
tartalmazza a saját konfigurációmásolatát, manifestjét és predikcióit. A
modellromlás okának eldöntéséhez a manifest hash-ek mellett a
run.log fitness-üzeneteit is össze kell vetni.
A leggyakoribb tévedés: „elég a seed, azzal reprodukálható a
futás.” Ellenpélda: két futtatás ugyanazzal a
seed = 20261020 értékkel történik. Közben valaki a
configs/tiny.toml-ben a C = 0.5 értéket
C = 5.0-ra módosította, és a make_tiny_data.py
futtatása új CSV-ket írt. A két manifest config.sha256 és
data.train.sha256 mezője eltér, tehát a két futás nem
ugyanaz a kísérlet, hiába azonos a seed. A seed csak a
véletlenszám-generátor bemenete; a konfiguráció, az adat, a kódverzió és
a környezet rögzítése nélkül nem elégséges. A manifest hash-ekkel és a
konfigurációmásolattal ezt a hiányt pótolja.
print-et?--run-id és az automatikusan generált
run id között a felülírás megelőzésében?A konfiguráció és futási napló a kutatói kódot visszakereshető
kísérletté alakítja. A TOML beépített, típusos konfigurációt ad; az
argparse csak a bemeneti határt kezeli. A logging szintek a zaj és a
hiba közötti különbséget rögzítik, a run id egyedi futáskönyvtárat
jelöl, a manifest pedig a konfiguráció, az adat és a környezet hash-eit
tárolja. Az immutable output a mkdir(exist_ok=False) és az
atomi os.replace miatt nem enged csendes felülírást. A
laborban egyetlen paranccsal két modell, a dummy és a logreg fut le,
külön predikciós CSV-vel és manifesttel. A következő lépés az Adatteszt és numerikus teszt: a manifestben
rögzített bemenetekhez és predikciókhoz adatteszteket és numerikus
teszteket kapcsolunk.