A 2.3. Izolált környezet, notebook és verzió egységben rögzítettük, melyik interpreter, kernel és lockolt csomagprofil futtatja a kódot. Ebben az alfejezetben a notebookban kipróbált cellát valódi, tesztelt szoftverprojektté alakítjuk. A cél nem az, hogy „szebb” kódot írjunk, hanem hogy a kísérlet bemenete, kimenete, konfigurációja és ellenőrzése együtt legyen újrafuttatható. Induló kérdés: mikor mondhatjuk, hogy a notebookbeli függvény és a modulba emelt változat ugyanazt a matematikai leképezést állítja elő, és mit tud ebből a teszt ténylegesen ellenőrizni?
Előismeret: a 2.3. Izolált környezet, notebook és verzió környezeti szerződése. Visszahívás az 1.4. Tanulási diagnózis és kísérleti napló egységből: a seed a generátor kezdőállapota, a futásazonosító a napló kulcsa; a kettő nem cserélhető fel.
Tiszta függvény. Egy Python-függvény akkor tiszta, ha referenciálisan átlátszó és nincs megfigyelhető mellékhatása:
A tisztaság a kód szerkezeti tulajdonsága, és független attól, hogy a függvény helyes-e. A helyesség külön állítás: ha a matematikai leképezés , akkor a Python-implementáció helyes, ha minden -re (lebegőpontos számításnál egy rögzített toleranciával). Egy teszt ennek a helyességnek csak véges sok esetét ellenőrzi; a tisztaság viszont azt biztosítja, hogy a tesztben látott eredmény nem függ a processz korábbi állapotától, ezért megismételhető. A fájlírás ezzel szemben eljárás, nem tiszta függvény.
Modul. Olyan Python-fájl, amely importálható névtérként szolgál. A notebookcella kódja modulba emelve függetleníthető a cellák végrehajtási sorrendjétől.
CLI. Parancssori interfész, amely a bemenetet, a kimenetet és az opciókat argumentumként kapja. Ezzel a konfiguráció nem a kódba égetett konstans, hanem a parancs része.
Manifest, futási jegyzék. A futás bemenetének,
paramétereinek, környezetének és kimeneti lenyomatának géppel
ellenőrizhető rögzítése. A GlyphLab manifest.json-ja
ugyanezt az elvet követi: a fájlhash és a splitdarabszám együttesen
azonosítja az adatállapotot.
Tesztfixture. Előre, determinisztikusan előállított bemenet és hozzá tartozó elvárt tulajdonság. A fixture nem „példa, ami véletlenül jó”, hanem a teszt szerződésének rögzített inputja.
Lokális artefaktum. A futtatás által létrehozott, nem forráskód jellegű fájl: modellkimenet, napló, generált manifest. A verziókezelőből kizárjuk, mert a futás eredménye, nem a projekt definíciója.
Git. Verziókezelő, amely a projekt forrásállapotát rögzíti. A commit a kód és a fixture időbeli kontrollja; a futás reprodukálásához szükség van még a környezeti lockra és a manifestre.
Állítás (refaktor-egyezés, gyenge determinisztikus lemma). Legyen a notebookban definiált függvény, a modulba emelt függvény. Tegyük fel, hogy
Ekkor bármely bemenetre .
Indoklás. A feltételek mellett a program determinisztikus: minden utasítás ugyanazt az átmenetet hajtja végre, függetlenül attól, hogy a kód fizikailag notebookcellában vagy modulban van-e. A szekvenciális végrehajtás definíciója szerint azonos kezdőállapotból azonos közbülső állapotok és azonos végeredmény adódik. Ez nyelvi szemantikai érvelés, nem mély matematikai tétel; az állítás ereje a feltételek explicitségében van. A teljes tartományra vonatkozó egyenlőséget ez az érvelés adja, a unit teszt önmagában csak véges mintán ellenőriz.
Mit nem ellenőrizhet a unit teszt? A teszt csak a fixture-ökre vonatkozik; nem bizonyítja a le nem fedett bemenetek viselkedését. Nem dönti el, hogy a függvény a tudományos konvenciónak megfelelő mennyiséget számítja-e (például populációs vagy mintabeli szórást). Nem ad platformok közötti bitazonossági garanciát: az azonos seed sem ígér azonos lebegőpontos eredményt más builden, ahogy a [D02] dokumentáció is rögzíti. Végül a zöld teszt nem helyettesíti a modellértékelés helyességét.
Ellenpélda a feltétel szükségességére. Ha a
notebookcella a törzsén kívül definiált, implicit
rng = np.random.default_rng() objektumot használ, a modulba
emelt változat más processzben más seed nélkül más kimenetet adhat. A
cella tehát nem volt tiszta; a refaktornak a generátort argumentummá
kell tennie, különben a lemma 1. feltétele sérül.
A labor egy oszloponkénti standardizáló függvényt emel ki. Legyen ; a oszlopra
és egy rögzített véges küszöbbel a kimenet
Ezt nevezzük -küszöbös populációs standardizálásnak. Az eset a tiszta matematikai operátor, amely csak a valóban konstans () oszlopot nullázza. Az küszöb skálafüggő degenerációt vezet be: minden olyan oszlop nullává válik, amelynek szórása alatti, akkor is, ha nem konstans. Például az oszlopra ; mellett a kimenet , a labor alapértelmezett küszöbével viszont nullavektor. A küszöb tehát a bemenet mértékegységéhez kötött döntés, nem numerikus apróság.
Kézi ellenőrzés három megfigyeléssel, két oszloppal:
Az első oszlop: , a központozott vektor , variancia , szórás . A standardizált oszlop ; átlaga , populációs varianciája pedig a négyzetes átlag:
A második oszlopra , a központozott vektor , ugyanaz a variancia, tehát az eredmény oszloponként ugyanazt a standardizált alakot adja. Egy konstans harmadik oszlop, például , miatt a konvenciót kapja.
A labor kiindulópontja a 2.3. Izolált környezet, notebook és verzió notebook egy cellája, amely a fenti standardizálást keveri egy ad hoc kiírással. A feladat a tiszta számítás kiemelése, majd a projekt felépítése.
A projektstruktúra:
| Fájl | Szerep | Commit? | Tartalom |
|---|---|---|---|
glyphstats/normalize.py |
tiszta függvény | igen | standardizálás |
glyphstats/cli.py |
parancssori belépés, manifest | igen | argparse + hash |
tests/test_normalize.py |
teszt | igen | fixture-ellenőrzés |
data/input.npy |
fixture | igen | determinisztikus bemenet |
runs/ |
lokális artefaktum | nem | kimenet és jegyzék |
A normalize.py:
"""Oszloponkénti standardizálás az n008 laborhoz."""
from __future__ import annotations
import numpy as np
def normalize_columns(x: np.ndarray, eps: float = 1e-12) -> np.ndarray:
"""Standardizálja az (N, d) mátrix oszlopait, eps-küszöbbel.
A szórás konvenció: populációs, 1/N normalizálással.
Ha egy oszlop szórása <= eps, a kimenet 0 (eps=0: csak a konstans oszlop).
"""
if x.ndim != 2:
raise ValueError("x.shape-nak (N, d) kétdimenziósnak kell lennie")
if not (np.isfinite(eps) and eps >= 0):
raise ValueError("eps-nek véges, nemnegatív számnak kell lennie")
centered = x - x.mean(axis=0, keepdims=True)
var = np.mean(centered * centered, axis=0, keepdims=True)
sd = np.sqrt(var)
denom = np.where(sd > eps, sd, 1.0)
out = centered / denom
out = np.where(sd > eps, out, 0.0)
return np.ascontiguousarray(out)A tools/make_fixture.py determinisztikus fixture-t állít
elő:
"""Az n008 tesztfixture generátora: seedelt, ismételhető."""
from __future__ import annotations
from pathlib import Path
import numpy as np
def main() -> None:
out_dir = Path("data")
out_dir.mkdir(exist_ok=True)
rng = np.random.default_rng(20261020)
x = rng.normal(loc=2.0, scale=4.0, size=(10, 3))
x[:, 2] = 5.0 # konstans oszlop a degenerált esethez
np.save(out_dir / "input.npy", x.astype(np.float64))
if __name__ == "__main__":
main()A cli.py explicit bemenetet, kimenetet és manifestet
kezel:
"""Parancssori belépőpont és futási jegyzék."""
from __future__ import annotations
import argparse
import hashlib
import json
import platform
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
from .normalize import normalize_columns
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(65536), b""):
digest.update(chunk)
return digest.hexdigest()
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="GlyphStats normalizáló CLI")
parser.add_argument("--input", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--manifest", type=Path, default=None)
parser.add_argument("--eps", type=float, default=1e-12)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
input_path = args.input.expanduser().resolve()
output_path = args.output.expanduser().resolve()
output_path.parent.mkdir(parents=True, exist_ok=True)
# Csak saját fixture-t töltünk; allow_pickle=False biztonsági okból.
x = np.load(input_path, allow_pickle=False).astype(np.float64, copy=False)
y = normalize_columns(x, eps=args.eps)
np.save(output_path, y)
if args.manifest is not None:
record = {
"run_id": uuid.uuid4().hex,
"created_utc": datetime.now(timezone.utc).isoformat(),
"input_file": input_path.name,
"input_sha256": sha256_file(input_path),
"output_file": output_path.name,
"output_sha256": sha256_file(output_path),
"params": {"eps": args.eps, "input_dtype": "float64"},
"python": sys.version.split()[0],
"numpy": np.__version__,
"machine": platform.platform(),
"normalization": "population_std",
}
manifest_path = args.manifest.expanduser().resolve()
manifest_path.parent.mkdir(parents=True, exist_ok=True)
manifest_path.write_text(json.dumps(record, indent=2), encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())A teszt tests/test_normalize.py:
"""Az n008 labor szerződését rögzítő unit tesztek."""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pytest
from glyphstats.normalize import normalize_columns
@pytest.fixture()
def input_array() -> np.ndarray:
path = Path(__file__).resolve().parent.parent / "data" / "input.npy"
return np.load(path, allow_pickle=False)
def test_output_shape_matches_input(input_array: np.ndarray) -> None:
y = normalize_columns(input_array)
assert y.shape == input_array.shape
assert y.dtype == np.float64
def test_normalized_columns_are_standardized(input_array: np.ndarray) -> None:
y = normalize_columns(input_array)
expected_std = np.array([1.0, 1.0, 0.0])
np.testing.assert_allclose(y.mean(axis=0), np.zeros(3), atol=1e-12)
np.testing.assert_allclose(y.std(axis=0), expected_std, atol=1e-12)
def test_zero_variance_columns_are_zero(input_array: np.ndarray) -> None:
y = normalize_columns(input_array)
np.testing.assert_array_equal(y[:, 2], np.zeros(input_array.shape[0]))
def test_eps_threshold_is_scale_dependent() -> None:
x = np.array([[0.0], [1e-13], [2e-13]])
np.testing.assert_array_equal(normalize_columns(x), np.zeros((3, 1))) # eps=1e-12
np.testing.assert_allclose(normalize_columns(x, eps=0.0).ravel(),
[-np.sqrt(1.5), 0.0, np.sqrt(1.5)], rtol=1e-9, atol=1e-12)
with pytest.raises(ValueError):
normalize_columns(x, eps=-1.0)
def test_rejects_non_matrix(input_array: np.ndarray) -> None:
with pytest.raises(ValueError):
normalize_columns(input_array[:, 0])A pyproject.toml a tanulási profilhoz:
[project]
name = "glyphstats"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["numpy>=2.0"]
[dependency-groups]
dev = ["pytest>=8"]
[tool.pytest.ini_options]
testpaths = ["tests"]A .gitignore a lokális artefaktumokat zárja ki:
__pycache__/
.pytest_cache/
runs/
A parancsok a projekt gyökeréből:
uv sync
uv run python tools/make_fixture.py
uv run pytest -q
uv run python -m glyphstats.cli \
--input data/input.npy \
--output runs/output.npy \
--manifest runs/manifest.jsonA futtatás után a jegyzék szerkezete a következő sémát követi; a hash- és időértékek futásonként generálódnak, ezért itt placeholderként szerepelnek, nem mért outputként:
{
"run_id": "<generált-uuid>",
"created_utc": "<ISO-8601 időbélyeg>",
"input_file": "input.npy",
"input_sha256": "<64 hex karakter>",
"output_file": "output.npy",
"output_sha256": "<64 hex karakter>",
"params": {"eps": 1e-12, "input_dtype": "float64"},
"python": "3.12.x",
"numpy": "2.x.y",
"machine": "<platform.platform() kimenet>",
"normalization": "population_std"
}A README.md első szakasza a 2.3.
Izolált környezet, notebook és verzió verziójelentését másolja be:
interpreter, kernel, lock-állapot. Így a projekt nemcsak fut, hanem
megmondja, milyen feltételek között futott.
F1 (fogalmi, nehézség 1). Magyarázza el, miért nem
tiszta függvény egy olyan next_batch(xs, batch_size)
függvény, amely modulszintű _cursor globális változót
léptet, és adjon olyan hívás-sorrendet, amelyben ugyanaz az argumentum
más értéket ad vissza. Elvárt átadandó: definíció szerinti indoklás és
konkrét ellenpélda.
F2 (levezetési, nehézség 2). Bizonyítsa be a fenti konvencióval: ha , akkor a standardizált oszlopának átlaga és populációs varianciája . Kezelje a esetet is. Elvárt: minden lépés, indexhatár és a degenerált eset indoklása.
F3 (implementációs, nehézség 1). Készítse el a labor
projektet, futtassa a négy tesztet, majd a CLI-t. Adja meg a
manifest.json run_id,
input_sha256 és output_sha256 kulcsait a
tényleges futásából. Elvárt átadandó: a parancsok, a tesztkimenet és a
manifest három kulcsa.
F4 (hibakeresési, nehézség 2). Az alábbi függvény dokumentációja mintabeli szórást ígér:
def misleading_standardize(x: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Visszaadja a standardizált mátrixot és a mintabeli szórást."""
mu = x.mean(axis=0)
sd = x.std(axis=0) # hibagyanús sor
denom = np.where(sd > 1e-12, sd, 1.0)
y = (x - mu) / denom
return y, sdLokalizálja a hibát, mutassa meg a következményét az vektoron, javítsa a kódot, és írjon tesztet, amely a javítás előtt megbukna. Elvárt: ok, következmény, javítás, visszaellenőrzés.
F5 (kísérlettervezési, nehézség 3). Tervezzen
ellenőrzést arra, hogy a GlyphLab data/images.npy mind a
360 képén a notebookból kiemelt régi függvény és az új modul azonos
kimenetet ad-e. Ne futtassa le a teljes kísérletet; írja le a
protokollt: input, környezet, a régi és új függvény egy processzben való
futtatása, metrika, tolerancia, és a következtetés korlátja. Indokolja,
miért nem elegendő az azonos seed, ha a platform megváltozik.
F1 megoldás. A tiszta függvény első feltétele sérül:
a kimenet függ a modul rejtett állapotától. Legyen
xs = [10, 20, 30], _cursor = 0; ekkor
next_batch(xs, 1) először [10]-et ad,
másodszor [20]-at, holott az argumentumok azonosak. A két
hívás között a globális állapot megváltozott, tehát a függvény nem
tisztán az argumentumaira épül.
F2 megoldás. Rögzítsük a oszlopot, és legyen . A központozott érték , a kimenet . Az átlag:
mert a zárójelben áll. A populációs variancia:
mert a központozott értékek populációs varianciája definíció szerint . Alakellenőrzés: , az átlag oszlopvektor -beli, a variancia nemnegatív skalárok sorozata. Ha , akkor a nemnegatív négyzetösszeg nulla, tehát minden -re , azaz ; a definiált konvenció , így az átlag , a variancia , és az érték ebben az esetben nem állítható.
F3 megoldás. A labor a fenti négy tesztet futtatja.
A tényleges futásból a manifest.json-t a hallgató olvassa
ki; a kulcsok szerkezete a sémában rögzített. A tesztkimenet várhatóan
négy sikeres egység, de e kézirat nem állítja, hogy a kódot lefuttatta;
a hallgatói munkamenet az elsődleges bizonyíték. Az ellenőrzéshez a
run_id 32 hex karakter hosszúságú, mindkét hash 64 hex
karakter.
F4 megoldás. A hiba oka: a dokumentáció mintabeli
szórást ígér, a NumPy std alapértelmezett
ddof=0 értéke viszont populációs normalizálást használ.
Következmény az
vektoron: a mintabeli szórás
,
a hibás kód viszont
-t
ad, tehát a jelentett bizonytalanság kisebb a dokumentált konvenciónál.
Javítás: sd = x.std(axis=0, ddof=1). Visszaellenőrző
teszt:
def test_misleading_standardize_reports_sample_sd() -> None:
x = np.array([[0.0], [4.0]])
_, sd = misleading_standardize(x)
np.testing.assert_allclose(sd, [2.0], atol=1e-12)A javítás előtt ez a teszt megbukik, mert ; utána a kívánt konvenciót rögzíti.
F5 megoldás (terv, nem mért eredmény). Input:
data/images.npy, alak (360,1,24,24), float32;
a sorrend a metadata.csv szerint rögzített. A régi
függvényt a kísérlet előtt változatlanul kimentjük egy
legacy.py fájlba; az új függvényt a projektmodulból
importáljuk. Ugyanabban a Python-processzben, ugyanazon a NumPy-verzión
és dtype-on futtatjuk mindkettőt minden képen. Metrika:
,
és az np.allclose(atol=1e-6, rtol=1e-5) eldöntés float32
mellett. A nullhelyzet: a refaktor nem változtatott. A 360 képén mért
egyezés csak a tesztelt tartományra empirikus bizonyíték; a teljes
tartományra a determinisztikus lemmából következő érvelés ad garanciát.
Platformváltáskor az azonos seed nem garantál bitazonosságot [D02],
ezért az összehasonlítást ugyanazon gépen kell elvégezni, vagy a
toleranciát a platform numerikus zajához kell kalibrálni.
Tipikus tévedés. „A tiszta kód és a zöld teszt garantálja a tudományos állítás helyességét.” Ez hamis. A kódminőség az implementáció kontrollja; a modellértékelés helyessége a konvención, a spliten és a metrikán múlik. Ellenpélda: a F4 hibás függvénye kifogástalanul strukturált, a standardizált outputra épített teszt mégis zöld, mert a hiba a dokumentált statisztikai konvenció és a kód közötti eltérésben van. A teszt csak azt rögzíti, amit explicit módon beleírunk.
Önellenőrzés. (1) Meg tudom-e mondani minden
függvényem bemeneti és kimeneti alakját? (2) A teszt a matematikai
tulajdonságot ellenőrzi, vagy csak a kód önmagára hivatkozik? (3) A
manifest tartalmazza-e a bemeneti hash-t, a környezetet és a
paramétereket? (4) A .gitignore kizárja-e a futás közben
keletkező fájlokat?
Visszahívás. Az 1.4. Tanulási
diagnózis és kísérleti napló megkülönböztette a seedet és a
futásazonosítót; itt mindkettő szerephez jut: a fixture a seedből
születik, a manifest run_id-ja a napló kulcsa. A 2.3. Izolált környezet, notebook és verzió
környezeti jelentése a README első szakasza lett; ezzel a
projekt a futtatási feltételeket a kóddal együtt szállítja.
A 2.4. Kis tudományos szoftverprojekt a
notebookcellát kis szoftverprojektté alakítja: a pathlib a
fájlútvonalakat, a modul a tiszta függvényt, a CLI az
input–output–manifest szerződést kezeli. A refaktor a bemenet–kimenet
leképezést őrzi; a determinisztikus lemma kimondja a feltételeket, a
unit teszt ezt véges fixture-ökön ellenőrzi. A zöld teszt nem bizonyítja
a teljes tartományt, és nem pótolja a statisztikai konvenció vagy a
modellértékelés helyességét. A futási jegyzék hash-t, környezetet és
paramétereket rögzít, a lokális artefaktumokat a .gitignore
zárja ki. A következő egység a lineáris algebrai alapokat építi fel.