Skip to main content

rustyml/
random.rs

1//! Crate-wide control of pseudo-random number generation for reproducibility
2//!
3//! Most randomized components in the crate draw their RNG through `make_rng` (or its sibling
4//! `make_rng_opt`, for callers that stay deterministic unless explicitly seeded). A single
5//! [`set_global_seed`] call makes them reproducible together. This routes randomness through one
6//! entry point for:
7//!
8//! - neural-network components: weight initialization, dropout/noise masks, and the
9//!   [`Sequential`](crate::neural_network::sequential::Sequential) minibatch shuffle
10//! - machine-learning estimators: k-means, SVC/LinearSVC, MeanShift, Isolation Forest, and others
11//! - utilities: `train_test_split`, t-SNE
12//!
13//! # Seed resolution
14//!
15//! `make_rng` resolves a per-consumer `random_state: Option<u64>` against the process-global
16//! (thread-local) seed as follows:
17//!
18//! - `Some(seed)`: use that seed. The global stays untouched
19//! - `None`, with a global seed set: derive an independent sub-seed from the global stream
20//! - `None`, with no global seed: seed from entropy (not reproducible)
21//!
22//! Because an explicit `Some` seed never consumes the global stream, adding or removing a seeded
23//! component does not change the seeds the unseeded ones get. Unseeded components, by contrast,
24//! draw from the shared stream in construction order, so their reproducibility is order-sensitive
25//! (this matches Keras' global-seed behavior)
26//!
27//! # Threading
28//!
29//! The global seed is **thread-local**: [`set_global_seed`] only affects the thread that calls
30//! it, so set the seed on the same thread that constructs your models. This is lock-free, and
31//! because the default test harness spawns a fresh thread per test, each test starts unseeded.
32//! Under `--test-threads=1`, however, all tests share one thread. A test that sets a global seed
33//! should call [`clear_global_seed`] afterwards, ideally with a drop guard so it runs even on
34//! panic. This avoids leaking the seed into a later test that expects unseeded behavior
35//!
36//! # Intentional exclusions
37//!
38//! Not every pseudo-random draw in the crate goes through this module. A draw is worth routing
39//! here only when it has a real, lasting effect on the result. The `utils` dimensionality
40//! reducers (`pca`, `kernel_pca`) are left out, for 2 reasons:
41//!
42//! - Their iterative eigensolvers (PCA's `PowerIteration`, and KernelPCA's `Lanczos` and
43//!   `PowerIteration`) seed a starting vector with a fixed constant. These methods converge to the
44//!   same eigenvectors regardless of the starting vector, so the seed only pins an arbitrary
45//!   eigenvector sign. It has no effect on reproducibility worth routing through the global seed
46//! - Randomized SVD (`SVDSolver::Randomized(u64)`) takes its seed as a public argument, so the
47//!   caller always supplies it. There is no unseeded path for the global to fill
48//!
49//! General rule: route a draw through this module only when an unseeded call would make a
50//! pseudo-random choice that changes the result
51
52use ndarray_rand::rand::{RngCore, SeedableRng, rng, rngs::StdRng};
53use std::cell::RefCell;
54
55thread_local! {
56    /// Per-thread global seed stream. `None` until `set_global_seed` is called on this thread
57    static GLOBAL_SEED_RNG: RefCell<Option<StdRng>> = const { RefCell::new(None) };
58}
59
60/// Sets the thread-local global seed
61///
62/// After this call, every component constructed **on this thread** with `random_state == None`
63/// becomes reproducible (it derives its RNG from the global stream). Call this before
64/// constructing the models/estimators whose randomness you want to fix
65///
66/// # Parameters
67///
68/// - `seed` - The seed for the thread-local global RNG stream
69pub fn set_global_seed(seed: u64) {
70    GLOBAL_SEED_RNG.with(|cell| *cell.borrow_mut() = Some(StdRng::seed_from_u64(seed)));
71}
72
73/// Clears the thread-local global seed, restoring entropy-based behavior for unseeded components
74///
75/// Useful to isolate tests that may share a thread (e.g. under `--test-threads=1`)
76pub fn clear_global_seed() {
77    GLOBAL_SEED_RNG.with(|cell| *cell.borrow_mut() = None);
78}
79
80/// Resolves a `random_state` into an RNG only when a seed is in effect. Returns `None` when
81/// there is none (`random_state` is `None` and no global seed is set)
82///
83/// This is for callers that should stay deterministic unless randomness is explicitly requested,
84/// e.g. a decision tree that breaks split ties randomly only when seeded. `Some(seed)` uses that
85/// seed and ignores the global. `None` derives a sub-seed from the thread-local global if one is
86/// set, or returns `None` otherwise (the signal: no randomization requested)
87///
88/// # Parameters
89///
90/// - `random_state` - The per-consumer seed, or `None` to defer to the global
91///
92/// # Returns
93///
94/// - `Option<StdRng>` - A seeded RNG if a local or global seed is active, else `None`
95pub(crate) fn make_rng_opt(random_state: Option<u64>) -> Option<StdRng> {
96    match random_state {
97        // Explicit local seed: independent, and does not touch the global stream
98        Some(seed) => Some(StdRng::seed_from_u64(seed)),
99        // No local seed: derive from the global stream if one is set, else signal "no seed"
100        None => GLOBAL_SEED_RNG.with(|cell| {
101            cell.borrow_mut()
102                .as_mut()
103                .map(|global| StdRng::seed_from_u64(global.next_u64()))
104        }),
105    }
106}
107
108/// Resolves a `random_state` into a concrete RNG (see the [module docs](self) for the rules)
109///
110/// This is the single entry point for all randomness in the crate. `Some` uses the given seed.
111/// `None` consults the thread-local global, deriving a sub-seed from it if one is set, or falls
112/// back to entropy
113///
114/// # Parameters
115///
116/// - `random_state` - The per-consumer seed, or `None` to defer to the global/entropy
117///
118/// # Returns
119///
120/// - `StdRng` - A freshly seeded RNG for the caller to own and advance
121pub(crate) fn make_rng(random_state: Option<u64>) -> StdRng {
122    // Falls back to entropy when no local or global seed exists
123    make_rng_opt(random_state).unwrap_or_else(|| StdRng::from_rng(&mut rng()))
124}