29.2 Konfiguráció és futási napló

29.2.1 Cél és konkrét helyzet

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.

29.2.2 Fogalmi magyarázat

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.

29.2.3 Teljes minimális munkapélda

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 = 500

Az adatgenerátor a korábbi Lineáris és logisztikus prediktív modellek alfejezetből ismert logit-függvénnyel sorsol 600 sort. Minden ii sorra:

pi=11+e−(β0+β1di+β2bi+β3ri),p_i=\frac{1}{1+e^{-(\beta_0+\beta_1 d_i+\beta_2 b_i+\beta_3 r_i)}},

ahol did_i a distance_km (km), bib_i a basket_minor (EUR-cent), ri∈{0,1}r_i\in\{0,1\} a priority_flag; β0=−2,0\beta_0=-2{,}0, β1=0,06\beta_1=0{,}06, β2=0,0001\beta_2=0{,}0001, β3=0,8\beta_3=0{,}8. A target_late 1, ha a véletlen szám kisebb pip_i-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 INFO

PowerShell-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 INFO

Szemlé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

29.2.4 Eredmény értelmezése

A dummy modell a tanító pozitív arányt p̂train\hat p_{\mathrm{train}} konstansként jósolja. A teszt-accuracy kézi kontrollja:

acĉ=p̂trainy‾test+(1−p̂train)(1−y‾test),\widehat{\mathrm{acc}}=\hat p_{\mathrm{train}}\bar y_{\mathrm{test}}+(1-\hat p_{\mathrm{train}})(1-\bar y_{\mathrm{test}}),

ahol y‾test\bar y_{\mathrm{test}} a tesztcímke átlaga. Ha például p̂train=0,4675\hat p_{\mathrm{train}}=0{,}4675 és y‾test=0,49\bar y_{\mathrm{test}}=0{,}49, akkor acĉ=0,4675⋅0,49+0,5325⋅0,51=0,5297\widehat{\mathrm{acc}}=0{,}4675\cdot0{,}49+0{,}5325\cdot0{,}51=0{,}5297; 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.

29.2.5 Feladatok

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

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

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

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

29.2.6 Megoldások és indoklás

  1. A DummyClassifier(strategy="prior") a tanító halmaz pozitív arányát tanulja meg, és minden teszt-sorra ugyanazt a pp-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.

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

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

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

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

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.

29.2.8 Önellenőrzés

  1. Miért írjuk a manifestet utoljára, és miért atomi írással?
  2. Milyen logging szintet választasz a modell AUC-jának kiírásához, és miért nem print-et?
  3. Mi a különbség a --run-id és az automatikusan generált run id között a felülírás megelőzésében?

29.2.9 Rövid összefoglaló

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.