Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions seedkit/README.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions seedkit/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
19 changes: 19 additions & 0 deletions seedkit/src/seedkit/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
55 changes: 55 additions & 0 deletions seedkit/src/seedkit/cli.py
Original file line number Diff line number Diff line change
@@ -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()
105 changes: 105 additions & 0 deletions seedkit/src/seedkit/seeding.py
Original file line number Diff line number Diff line change
@@ -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)
31 changes: 31 additions & 0 deletions seedkit/tests/test_seeding.py
Original file line number Diff line number Diff line change
@@ -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