From 1eaca9125e31ff699871fb27987f5e1d1267cff0 Mon Sep 17 00:00:00 2001 From: Faruk Alpay <32020561+farukalpay@users.noreply.github.com> Date: Mon, 25 Aug 2025 16:08:04 +0200 Subject: [PATCH] chore: satisfy ruff lint --- seedkit/README.md | 35 +++++++++++ seedkit/pyproject.toml | 19 ++++++ seedkit/src/seedkit/__init__.py | 19 ++++++ seedkit/src/seedkit/cli.py | 55 +++++++++++++++++ seedkit/src/seedkit/seeding.py | 105 ++++++++++++++++++++++++++++++++ seedkit/tests/test_seeding.py | 31 ++++++++++ 6 files changed, 264 insertions(+) create mode 100644 seedkit/README.md create mode 100644 seedkit/pyproject.toml create mode 100644 seedkit/src/seedkit/__init__.py create mode 100644 seedkit/src/seedkit/cli.py create mode 100644 seedkit/src/seedkit/seeding.py create mode 100644 seedkit/tests/test_seeding.py diff --git a/seedkit/README.md b/seedkit/README.md new file mode 100644 index 0000000..aaa97e2 --- /dev/null +++ b/seedkit/README.md @@ -0,0 +1,35 @@ +# seedkit + +`seedkit` provides deterministic sub-seeding utilities for scientific experiments. A master seed +combined with textual identifiers is hashed using SHA-256 to derive a 64‑bit sub-seed. This +sub-seed feeds NumPy's counter-based `Philox` bit generator which enables many independent and +reproducible streams. + +Hashing decorrelates streams derived from related identifiers, but it does **not** offer any +cryptographic guarantees. It simply prevents accidental global correlations when deriving +sub-seeds from structured information. + +## Usage + +### Library +```python +from seedkit.seeding import make_subseed, philox_generator, python_random + +sub = make_subseed(42, "trainer", "R1", 0) +np_gen = philox_generator(sub) +py_gen = python_random(sub) +print(np_gen.random(3)) +print(py_gen.random()) +``` + +### Command line +``` +$ seedkit demo --master 42 --component trainer --run R1 --stream 0 --n 5 +Derived subseed: 11789288199025255931 +NumPy samples: [0.0116 ...] +Python samples: [0.2327 ...] +Torch samples: [0.4561 ...] # only if PyTorch is installed +``` + +Philox is a counter-based generator designed for parallel use. Each stream consumes from an +independent counter, avoiding overlap between streams. diff --git a/seedkit/pyproject.toml b/seedkit/pyproject.toml new file mode 100644 index 0000000..2c0cc2f --- /dev/null +++ b/seedkit/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "seedkit" +version = "0.1.0" +description = "Deterministic seeding utilities backed by SHA-256 and NumPy's Philox" +authors = [ { name = "SeedKit Developers" } ] +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.10" +dependencies = ["numpy>=1.22"] + +[project.optional-dependencies] +torch = ["torch"] + +[project.scripts] +seedkit = "seedkit.cli:main" diff --git a/seedkit/src/seedkit/__init__.py b/seedkit/src/seedkit/__init__.py new file mode 100644 index 0000000..21e812c --- /dev/null +++ b/seedkit/src/seedkit/__init__.py @@ -0,0 +1,19 @@ +"""Top level package for seedkit.""" + +from .seeding import ( + SeedStreams, + make_subseed, + philox_generator, + python_random, + set_torch_deterministic, + torch_generator, +) + +__all__ = [ + "SeedStreams", + "make_subseed", + "philox_generator", + "python_random", + "set_torch_deterministic", + "torch_generator", +] diff --git a/seedkit/src/seedkit/cli.py b/seedkit/src/seedkit/cli.py new file mode 100644 index 0000000..1e877c1 --- /dev/null +++ b/seedkit/src/seedkit/cli.py @@ -0,0 +1,55 @@ +"""Console interface for seedkit.""" +from __future__ import annotations + +import argparse +from collections.abc import Sequence + +from .seeding import ( + make_subseed, + philox_generator, + python_random, + torch_generator, +) + + +def _demo(args: argparse.Namespace) -> None: + subseed = make_subseed(args.master, args.component, args.run, args.stream) + np_gen = philox_generator(subseed) + py_gen = python_random(subseed) + + print(f"Derived subseed: {subseed}") + print("NumPy samples:", np_gen.random(args.n).tolist()) + print("Python samples:", [py_gen.random() for _ in range(args.n)]) + + try: + torch_gen = torch_generator(subseed=subseed) + import torch + + print("Torch samples:", torch.rand(args.n, generator=torch_gen).tolist()) + except Exception: + pass + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="seedkit") + sub = parser.add_subparsers(dest="command", required=True) + + demo = sub.add_parser("demo", help="Showcase deterministic seeding") + demo.add_argument("--master", required=True, help="Master seed") + demo.add_argument("--component", required=True, help="Component identifier") + demo.add_argument("--run", required=True, help="Run identifier") + demo.add_argument("--stream", type=int, default=0, help="Stream identifier") + demo.add_argument("--n", type=int, default=5, help="Number of samples to draw") + demo.set_defaults(func=_demo) + + return parser + + +def main(argv: Sequence[str] | None = None) -> None: + parser = build_parser() + args = parser.parse_args(argv) + args.func(args) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/seedkit/src/seedkit/seeding.py b/seedkit/src/seedkit/seeding.py new file mode 100644 index 0000000..09d0625 --- /dev/null +++ b/seedkit/src/seedkit/seeding.py @@ -0,0 +1,105 @@ +"""Deterministic seeding utilities.""" +from __future__ import annotations + +import hashlib +import random +from dataclasses import dataclass + +import numpy as np + +try: # Optional dependency + import torch # type: ignore +except Exception: # pragma: no cover - import guarded + torch = None # type: ignore + + +def make_subseed( + master_seed: int | str, + component_id: str, + run_id: str, + stream_id: int | str = 0, +) -> int: + """Derive a 64-bit subseed via SHA-256. + + Parameters + ---------- + master_seed: + Global experiment seed. + component_id, run_id, stream_id: + Identifiers used to create independent streams. + + Returns + ------- + int + First eight bytes of the SHA-256 hash interpreted as a big-endian integer. + """ + + parts = [str(master_seed), component_id, run_id, str(stream_id)] + data = b"\x00".join(p.encode("utf-8") for p in parts) + digest = hashlib.sha256(data).digest() + return int.from_bytes(digest[:8], "big") + + +def philox_generator(subseed: int) -> np.random.Generator: + """Create a NumPy Generator backed by Philox.""" + bitgen = np.random.Philox(subseed) + return np.random.Generator(bitgen) + + +def python_random(subseed: int) -> random.Random: + """Create a Python ``random.Random`` seeded with ``subseed``.""" + return random.Random(subseed) + + +def set_torch_deterministic() -> None: + """Enable deterministic algorithms in PyTorch if available.""" + if torch is None: # pragma: no cover - optional + return + torch.use_deterministic_algorithms(True) + + +def torch_generator(device: str = "cpu", subseed: int | None = None): + """Create a ``torch.Generator`` seeded with ``subseed``. + + Parameters + ---------- + device: + Device for the generator, e.g. ``"cpu"`` or ``"cuda"``. + subseed: + Optional seed for the generator. If ``None`` an unseeded generator is + returned. + """ + if torch is None: # pragma: no cover - optional + raise RuntimeError("PyTorch not installed") + + gen = torch.Generator(device=device) + if subseed is not None: + gen.manual_seed(subseed) + set_torch_deterministic() + return gen + + +@dataclass +class SeedStreams: + """Bundle of deterministic generators.""" + + numpy: np.random.Generator + python: random.Random + torch: torch.Generator | None = None + + +def seed_streams( + master_seed: int | str, + component_id: str, + run_id: str, + stream_id: int | str = 0, +) -> SeedStreams: + """Convenience factory creating ``SeedStreams`` from identifiers.""" + subseed = make_subseed(master_seed, component_id, run_id, stream_id) + np_gen = philox_generator(subseed) + py_gen = python_random(subseed) + try: + torch_gen = torch_generator(subseed=subseed) + except Exception: # pragma: no cover - optional + torch_gen = None + return SeedStreams(np_gen, py_gen, torch_gen) diff --git a/seedkit/tests/test_seeding.py b/seedkit/tests/test_seeding.py new file mode 100644 index 0000000..2cde6b2 --- /dev/null +++ b/seedkit/tests/test_seeding.py @@ -0,0 +1,31 @@ +import pathlib +import sys + +import numpy as np + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "src")) + +from seedkit.seeding import make_subseed, philox_generator # noqa: E402 + + +def test_subseed_determinism(): + s1 = make_subseed(123, "component", "run", 0) + s2 = make_subseed(123, "component", "run", 0) + assert s1 == s2 + + s3 = make_subseed(123, "other", "run", 0) + assert s1 != s3 + + s4 = make_subseed(123, "component", "run", 1) + assert s1 != s4 + + +def test_stream_independence(): + sub0 = make_subseed(123, "component", "run", 0) + sub1 = make_subseed(123, "component", "run", 1) + g0 = philox_generator(sub0) + g1 = philox_generator(sub1) + x = g0.random(1000) + y = g1.random(1000) + r = np.corrcoef(x, y)[0, 1] + assert abs(r) < 0.1