Reproducibility¶
Bit-identical replay of a sparho run on a different machine — or three
months later on the same machine — requires three things to line up:
the seed, the BLAS thread count, and the dependency versions.
This page documents the discipline sparho uses to make that possible
and what you should do to consume it.
TL;DR¶
OMP_NUM_THREADS=1 \
MKL_NUM_THREADS=1 \
OPENBLAS_NUM_THREADS=1 \
VECLIB_MAXIMUM_THREADS=1 \
NUMEXPR_NUM_THREADS=1 \
uv run python your_script.py
For tests and benchmarks, prefer the helper:
from sparho.testing import pin_blas_threads
with pin_blas_threads(1):
# everything inside this block runs single-threaded BLAS.
result = hoag_search(...)
Why BLAS threads matter¶
Multi-threaded BLAS (OpenBLAS, MKL, Accelerate) computes reductions
(dot products, matrix–matrix multiplies) by splitting the work across
threads and summing the partial results in the order the threads happen
to finish. Floating-point addition is not associative, so the final sum
is not bit-identical run-to-run — the high-order bits agree, the last
few mantissa bits drift. For sparho’s inner-solver tolerance regime
(1e-8 to 1e-10), that drift is enough to push the active set across
its threshold and flip the entire downstream search trajectory.
Single-threaded BLAS is deterministic: the reduction order is fixed,
the same inputs produce the same bits. The cost is wall-time
(BLAS-bound operations no longer scale across cores), but for the inner
solvers sparho targets — small-active-set Lasso/ElasticNet/Group —
the wall-time hit is modest.
The four environment variables¶
Numpy and scipy read these on first BLAS call, not on every call:
Variable |
Backend |
|---|---|
|
OpenMP (used by OpenBLAS, MKL fallback) |
|
Intel MKL |
|
OpenBLAS |
|
Apple Accelerate (macOS) |
|
NumExpr (transitive dep of pandas / xarray) |
|
BLIS (some scientific Linux distros) |
If your script imports numpy before the env vars are set, the
threadpool is already baked in. sparho.testing.pin_blas_threads()
handles this by additionally calling
threadpoolctl.threadpool_limits(),
which retunes the live pool. Without threadpoolctl, only future
subprocesses see the change.
Seed discipline¶
Every sparho API that consumes randomness exposes a random_state
keyword and is bit-identical at the same seed (under single-threaded
BLAS). The places randomness enters:
CrossVal.kfold(..., random_state=seed)— shuffled fold splits.Sure(sigma=..., random_state=seed)— the FDMC probeδ.Inner-solver warm-start cache: deterministic given the outer-loop trajectory, so seeding
CrossVal/Surecovers it.
The test suite asserts bit-equality of SearchResult.best_hyperparam
and best_coef at fixed seed in tests/test_determinism.py and across
the BLAS-threads × seed matrix in tests/test_determinism_matrix.py.
Dependency versions¶
For exact reproducibility, pin the dependency closure. The repository provides:
pyproject.toml— the floor versions (numpy>=1.24,scipy>=1.10,scikit-learn>=1.3). Theci-min-depsjob exercises these to guard against silent floor regressions.uv.lock— the resolved lockfile for the dev/test environment. Runuv sync --extra devto materialize it.requirements-bench.txt— auv pip compile-derived lockfile for the benchmark suite specifically (numpy + scipy + sklearn + celer + libsvmdata + matplotlib + pandas at known-good versions). Refresh quarterly withuv pip compile pyproject.toml --extra bench -o requirements-bench.txt.
What pin_blas_threads does (and does not) guarantee¶
Guarantee |
Mechanism |
|---|---|
Future subprocesses see |
Env vars updated in |
BLAS calls inside this process see |
|
Env vars restored on context-manager exit |
|
Bit-identical |
Single-threaded BLAS |
Bit-identical floats across BLAS backends (MKL vs OB) |
No. Different rounding paths. |
Bit-identical across CPU microarchitectures |
No. AVX-512 vs AVX2 differ. |
Bit-identical across numpy/scipy versions |
No. Backed by the lockfile. |
For cross-backend reproducibility, pin both the BLAS backend (e.g.
install numpy from numpy/openblas vs numpy/mkl-fft) and the
version. For most academic-publication purposes, pinning the lockfile
and OMP_NUM_THREADS=1 is enough — the active set, β*, and search
trajectory will match. Last-bit numerical agreement on residuals is
rarely the meaningful invariant.
Determinism audit matrix¶
tests/test_determinism_matrix.py re-runs the canonical
grad_search / hoag_search paths at three BLAS-thread counts
({1, 2, 4}) × two seeds × two criteria (CrossVal, Sure). At
n_threads=1 it asserts bit-equality of best_hyperparam and
best_coef across reruns. At n_threads > 1 it asserts equality
within a tight tolerance — the test serves as both a regression
guard and a calibration of the multi-thread drift envelope.
Run locally with:
uv run pytest tests/test_determinism_matrix.py -v
Benchmark provenance¶
Every run of benchmarks/lasso_libsvm.py emits a provenance.json
alongside its results.json. The provenance records CPU model, OS,
Python / numpy / scipy / sklearn / celer versions, the BLAS backend
resolved via np.show_config(), every BLAS env var at run time, and
the git SHA of the working tree. Reviewers reproducing a published
number should be able to diff their provenance against the one in the
paper artifact and immediately see what changed.
benchmarks/render_tables.py regenerates the Markdown tables in
benchmarks/README.md from the result JSONs, so the published numbers
never drift from the underlying measurements.