Skip to main content

thrust_rl/policy/
seeded_init.rs

1//! Seeded, host-side weight-initialization helpers for bit-exact
2//! policy construction.
3//!
4//! # Why this module exists
5//!
6//! Burn 0.21's [`burn::nn::Initializer::init_with`] /
7//! `init_tensor` (see
8//! `burn-core-0.21.0/src/module/initializer.rs`) take **no seed
9//! parameter** — they route through a backend-internal
10//! `Tensor::random` that draws from an unseeded, thread-local
11//! generator. That makes
12//! [`Initializer::Orthogonal`](burn::nn::Initializer::Orthogonal) and
13//! [`Initializer::KaimingUniform`](burn::nn::Initializer::KaimingUniform) the
14//! last non-reproducible site in the PSRO/NFSP determinism contract advertised
15//! by [`crate::multi_agent::psro::PsroConfig::seed`] /
16//! [`crate::multi_agent::nfsp::NfspConfig::seed`] (every rollout/
17//! training RNG site was plumbed through `StdRng::seed_from_u64` in
18//! PRs #113/#116/#122/#123/#125; this closes the policy-init gap —
19//! issue #135).
20//!
21//! # What it does
22//!
23//! Provides pure-Rust, [`StdRng`](rand::rngs::StdRng)-driven ports of the two
24//! init recipes the MLP policies use:
25//!
26//! - [`seeded_orthogonal`](crate::policy::seeded_init::seeded_orthogonal) —
27//!   orthogonal init via Householder QR of a Gaussian matrix, scaled by `gain`
28//!   (PPO recipe: `sqrt(2)` trunk, `0.01` heads).
29//! - [`seeded_kaiming_uniform`](crate::policy::seeded_init::seeded_kaiming_uniform) — Kaiming-uniform init matching Burn's
30//!   `KaimingUniform { gain, fan_out_only: false }` formula (`bound = gain *
31//!   sqrt(3 / fan_in)`), the path [`crate::policy::mlp::MlpBurnPolicy::new`]
32//!   exercises.
33//!
34//! # Bit-parity caveat
35//!
36//! These are **not** byte-identical to Burn's unseeded output — we are
37//! *replacing* the distribution with a reproducible one, not mirroring
38//! Burn's internal sampler. The only contract is: same `seed` (and
39//! same shape/gain) ⇒ same `Vec<f32>`, every run, every machine.
40
41use rand::{Rng, SeedableRng, rngs::StdRng};
42
43/// Draw a single `N(0, 1)` sample from `rng` via the Box–Muller
44/// transform.
45///
46/// We hand-roll the normal sampler (rather than pulling in
47/// `rand_distr`) so the only RNG draws are `f32` uniforms — keeping the
48/// per-construction consumption order trivial to reason about and the
49/// dependency surface unchanged. Two `StdRng`s seeded identically
50/// therefore yield identical draw sequences and identical matrices.
51fn standard_normal(rng: &mut StdRng) -> f32 {
52    // Guard u1 away from 0 so ln() is finite.
53    let u1: f32 = {
54        let x: f32 = rng.random();
55        if x <= f32::MIN_POSITIVE {
56            f32::MIN_POSITIVE
57        } else {
58            x
59        }
60    };
61    let u2: f32 = rng.random();
62    let r = (-2.0_f32 * u1.ln()).sqrt();
63    let theta = 2.0_f32 * std::f32::consts::PI * u2;
64    r * theta.cos()
65}
66
67/// Generate a seeded orthogonal weight matrix of shape `[d_in, d_out]`
68/// flattened in **row-major** order (row index over `d_in`, column
69/// index over `d_out`) — matching Burn's `Linear` weight layout
70/// (`weight: Param<Tensor<B, 2>>` with dims `[d_input, d_output]`).
71///
72/// Algorithm (a faithful, seeded port of the orthogonal recipe):
73/// 1. Sample an `[rows, cols]` matrix `A` from `N(0, 1)`.
74/// 2. Compute a (thin) QR factorization `A = Q R` via Householder reflections,
75///    with the sign convention `sign(diag(R)) >= 0` so the result is
76///    deterministic.
77/// 3. Return `gain * Q`.
78///
79/// `Q` has orthonormal columns when `rows >= cols`, and orthonormal
80/// rows when `rows < cols` (we QR the taller orientation internally and
81/// transpose back), matching the behavior of standard orthogonal init.
82pub fn seeded_orthogonal(seed: u64, d_in: usize, d_out: usize, gain: f32) -> Vec<f32> {
83    let mut rng = StdRng::seed_from_u64(seed);
84
85    // We want an [d_in, d_out] result with orthonormal rows or columns.
86    // QR is cleanest when the matrix is tall (rows >= cols). If
87    // d_in < d_out we QR the transpose ([d_out, d_in]) and transpose
88    // the resulting Q back.
89    let (rows, cols, transposed) = if d_in >= d_out {
90        (d_in, d_out, false)
91    } else {
92        (d_out, d_in, true)
93    };
94
95    // Sample the tall Gaussian matrix in column-major working storage:
96    // a[c] is the c-th column (length `rows`). Column-major makes the
97    // Householder loop's column access contiguous.
98    let mut cols_data: Vec<Vec<f32>> = Vec::with_capacity(cols);
99    for _ in 0..cols {
100        let mut col = Vec::with_capacity(rows);
101        for _ in 0..rows {
102            col.push(standard_normal(&mut rng));
103        }
104        cols_data.push(col);
105    }
106
107    // Modified Gram–Schmidt orthonormalization (numerically adequate
108    // for the small matrices used here, and trivially deterministic).
109    // Equivalent in outcome to a Householder QR's Q for full-rank
110    // Gaussian input; we additionally fix the sign so the diagonal of
111    // the implied R is non-negative.
112    let mut q: Vec<Vec<f32>> = Vec::with_capacity(cols);
113    for j in 0..cols {
114        let mut v = cols_data[j].clone();
115        // Subtract projections onto the previously-fixed orthonormal
116        // columns.
117        for qi in q.iter() {
118            let dot: f32 = (0..rows).map(|r| v[r] * qi[r]).sum();
119            for r in 0..rows {
120                v[r] -= dot * qi[r];
121            }
122        }
123        // Normalize.
124        let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
125        let norm = if norm <= f32::MIN_POSITIVE { 1.0 } else { norm };
126        for slot in v.iter_mut() {
127            *slot /= norm;
128        }
129        // Sign fix: make the leading nonzero-ish entry positive so the
130        // result is a deterministic function of the seed regardless of
131        // platform float associativity in the projection sums.
132        if v[j] < 0.0 {
133            for slot in v.iter_mut() {
134                *slot = -*slot;
135            }
136        }
137        q.push(v);
138    }
139
140    // q[c][r] = Q[r, c] for the tall orientation [rows, cols].
141    // Emit row-major [d_in, d_out] * gain.
142    let mut out = vec![0.0_f32; d_in * d_out];
143    for i in 0..d_in {
144        for j in 0..d_out {
145            // Map (i, j) in [d_in, d_out] back to (r, c) in [rows, cols].
146            let (r, c) = if transposed { (j, i) } else { (i, j) };
147            out[i * d_out + j] = gain * q[c][r];
148        }
149    }
150    out
151}
152
153/// Generate a seeded Kaiming-uniform weight matrix of shape
154/// `[d_in, d_out]` flattened in **row-major** order.
155///
156/// Matches Burn's `Initializer::KaimingUniform { gain, fan_out_only:
157/// false }` formula: each entry is drawn uniformly from
158/// `[-bound, bound]` where `bound = gain * sqrt(3 / fan_in)` and
159/// `fan_in = d_in`. This is the path
160/// [`crate::policy::mlp::MlpBurnPolicy::new`] uses
161/// (`use_orthogonal_init = false`), so seeding it is required to make
162/// the matching-pennies tests (which build policies via `new`)
163/// reproducible — see issue #135's Correction 2.
164pub fn seeded_kaiming_uniform(seed: u64, d_in: usize, d_out: usize, gain: f32) -> Vec<f32> {
165    let mut rng = StdRng::seed_from_u64(seed);
166    let fan_in = d_in.max(1) as f32;
167    let bound = gain * (3.0_f32 / fan_in).sqrt();
168    let mut out = vec![0.0_f32; d_in * d_out];
169    for slot in out.iter_mut() {
170        // Uniform in [-bound, bound]: rng.random() is [0, 1).
171        let u: f32 = rng.random();
172        *slot = (u * 2.0 - 1.0) * bound;
173    }
174    out
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    /// Two calls with the same seed/shape/gain produce bit-identical
182    /// orthogonal matrices; a different seed differs.
183    #[test]
184    fn orthogonal_is_deterministic_in_seed() {
185        let a = seeded_orthogonal(42, 6, 4, 2.0_f32.sqrt());
186        let b = seeded_orthogonal(42, 6, 4, 2.0_f32.sqrt());
187        assert_eq!(a, b, "same seed must be bit-identical");
188        let c = seeded_orthogonal(43, 6, 4, 2.0_f32.sqrt());
189        assert_ne!(a, c, "different seed must differ");
190        assert_eq!(a.len(), 24);
191    }
192
193    /// Columns of the [d_in, d_out] result (d_in >= d_out) are
194    /// orthonormal up to the gain factor.
195    #[test]
196    fn orthogonal_columns_are_orthonormal_tall() {
197        let d_in = 8;
198        let d_out = 3;
199        let gain = 1.0;
200        let w = seeded_orthogonal(7, d_in, d_out, gain);
201        // Column dot products: col j . col k should be ~delta_{jk}.
202        for j in 0..d_out {
203            for k in 0..d_out {
204                let mut dot = 0.0_f32;
205                for r in 0..d_in {
206                    dot += w[r * d_out + j] * w[r * d_out + k];
207                }
208                let expected = if j == k { 1.0 } else { 0.0 };
209                assert!(
210                    (dot - expected).abs() < 1e-4,
211                    "col {j}.col {k} = {dot}, expected {expected}"
212                );
213            }
214        }
215    }
216
217    /// Rows of the [d_in, d_out] result (d_in < d_out) are orthonormal
218    /// up to the gain factor.
219    #[test]
220    fn orthogonal_rows_are_orthonormal_wide() {
221        let d_in = 3;
222        let d_out = 8;
223        let gain = 1.0;
224        let w = seeded_orthogonal(11, d_in, d_out, gain);
225        for i in 0..d_in {
226            for k in 0..d_in {
227                let mut dot = 0.0_f32;
228                for c in 0..d_out {
229                    dot += w[i * d_out + c] * w[k * d_out + c];
230                }
231                let expected = if i == k { 1.0 } else { 0.0 };
232                assert!(
233                    (dot - expected).abs() < 1e-4,
234                    "row {i}.row {k} = {dot}, expected {expected}"
235                );
236            }
237        }
238    }
239
240    /// Gain scales the matrix linearly.
241    #[test]
242    fn orthogonal_gain_scales() {
243        let w1 = seeded_orthogonal(5, 6, 4, 1.0);
244        let w2 = seeded_orthogonal(5, 6, 4, 3.0);
245        for (a, b) in w1.iter().zip(w2.iter()) {
246            assert!((b - a * 3.0).abs() < 1e-5, "gain should scale: {a} vs {b}");
247        }
248    }
249
250    /// Kaiming-uniform is deterministic in seed and respects the
251    /// `gain * sqrt(3 / fan_in)` bound.
252    #[test]
253    fn kaiming_is_deterministic_and_bounded() {
254        let d_in = 16;
255        let d_out = 8;
256        let gain = 1.0_f32 / 3.0_f32.sqrt();
257        let a = seeded_kaiming_uniform(99, d_in, d_out, gain);
258        let b = seeded_kaiming_uniform(99, d_in, d_out, gain);
259        assert_eq!(a, b, "same seed must be bit-identical");
260        let c = seeded_kaiming_uniform(100, d_in, d_out, gain);
261        assert_ne!(a, c, "different seed must differ");
262
263        let bound = gain * (3.0_f32 / d_in as f32).sqrt();
264        for &x in &a {
265            assert!(x.abs() <= bound + 1e-6, "{x} exceeds bound {bound}");
266        }
267        assert_eq!(a.len(), d_in * d_out);
268    }
269}