Skip to content

fix: seed VBPCA initialization via random_state (#109) - #113

Merged
jc-macdonald merged 3 commits into
mainfrom
fix/seed-init-random-state
Aug 19, 2026
Merged

fix: seed VBPCA initialization via random_state (#109)#113
jc-macdonald merged 3 commits into
mainfrom
fix/seed-init-random-state

Conversation

@jc-macdonald

Copy link
Copy Markdown
Collaborator

Summary

  • Add a random_state: int | np.random.Generator | None constructor kwarg to VBPCA, following the sklearn convention, controlling both parameter initialization and any auto-generated xprobe mask.
  • Fix the root cause: the default init="random" option was truthy, so the old ternary in _initialize_parameters never seeded from a user-controllable source — it silently fell through to init_params()'s internal hardcoded default_rng(0) fallback. Default fits were deterministic, but not configurably so.
  • Behavior change: random_state=None (the default) now draws fresh entropy on every fit(), matching sklearn semantics. Callers relying on the old accidental seed-0 determinism should pass random_state=<int> explicitly.
  • get_options() (a separate options-rebuild path from fit()) was also missing the random_state wiring — fixed so resolved options correctly reflect the configured value.

Commits

  1. feat: add random_state parameter for reproducible initialization — core fix across _full_update.py, _pca_full.py, estimators.py, plus fixups to 12 existing tests that implicitly depended on the old accidental determinism.
  2. test: verify random_state reproducibility — 9 new tests covering default/forwarding/reproducibility/divergence/xprobe-mask/Generator-instance/get_params/set_params behavior, plus the get_options() fix found while writing them.
  3. docs: changelog and limitations entry for random_state — CHANGELOG Unreleased entry and a new bullet in docs/limitations.md flagging the non-reproducible-by-default behavior change.

Closes #109

Test plan

  • just ci (format-check, lint --preview, mypy --strict, test-cov) green locally: 479 passed, 90.21% coverage (gate 89%)
  • Verified no Octave/MATLAB parity tests touch the init/randomness code path
  • Re-ran test suite multiple times to confirm no flakiness from genuine randomness in default-configuration tests

🤖 Generated with Claude Code

VBPCA's fallback random initialization (loadings/scores when no explicit
init is given) and its auto-generated xprobe mask were both effectively
unseeded, with no way for callers to control or reproduce them.

Root cause was subtler than a single missing seed: _initialize_parameters
decided whether to pass an RNG using a truthiness check on the raw `init`
option. Since _build_options()'s default is the *string* "random" (truthy),
the common default-configuration path was actually taking the "no new
seed" branch and falling through to init_params' own internal fallback of
a hardcoded `np.random.default_rng(0)` -- silently deterministic, but not
user-controllable, and inconsistent with the genuinely-unseeded branch
that fired when `init=None` was passed explicitly. Both "random" and None
normalize to the same "no fixture" case one level down in
init_params -> _normalize_init, so they should behave identically.

Fix: VBPCA.__init__ gains `random_state: int | np.random.Generator | None
= None`, threaded through _build_options()/_full_update.py to unconditionally
seed the fallback init RNG via np.random.default_rng(random_state), and
threaded into fit()'s auto xprobe-mask generation the same way. random_state
follows sklearn's convention: None (default) draws fresh entropy each call.

This changes the default (unseeded) case from silently-deterministic-at-
seed-0 to genuinely random each call, matching what "no explicit seed"
should mean and what several existing tests' names/comments already
assumed was happening. Fixed 12 existing tests that implicitly depended
on the old accidental determinism -- either regression tests comparing
against a hardcoded value (add random_state=0, which reproduces the exact
old default-path behavior since it was already np.random.default_rng(0)
under the hood), or tests comparing two independent calls that need a
shared seed to be comparable at all.
Covers: default is None, forwarded into resolved options, same seed ->
bit-identical fit, different seeds -> diverge, None -> genuinely
non-reproducible across calls, the auto xprobe-mask path specifically,
accepting an existing Generator instance (not just an int), and
get_params()/set_params() round-tripping.

Also fixes get_options() -- it independently rebuilds the same options
dict fit() constructs (rather than calling a shared helper), and was
missing the random_state wire-up added to fit() in the previous commit,
so it always reported random_state=None regardless of what was
configured. Caught by test_random_state_forwarded_through_estimator.
@jc-macdonald
jc-macdonald merged commit 75f8bc0 into main Aug 19, 2026
7 checks passed
@jc-macdonald
jc-macdonald deleted the fix/seed-init-random-state branch August 19, 2026 12:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unseeded np.random.default_rng() in _full_update.py breaks reproducibility of model initialization

1 participant