Skip to main content

solow_decomposition/
random_projection.rs

1//! `GaussianRandomProjection` and `SparseRandomProjection` — the reference
2//! Johnson-Lindenstrauss lemma-inspired dimensionality reducers.
3//!
4//! Both project `X ∈ ℝ^{n × d}` to `X · Rᵀ ∈ ℝ^{n × k}` where `R` is a
5//! random `(k × d)` matrix. Distances between pairs of rows are preserved
6//! up to a `(1 ± ε)` distortion with high probability when
7//! `k = Θ(log(n)/ε²)`.
8
9use ndarray::{Array2, ArrayView2};
10use solow_core::{Error, Result};
11
12/// GaussianRandomProjection — entries of `R` are `𝒩(0, 1/k)`.
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14#[derive(Clone, Debug, PartialEq)]
15pub struct GaussianRandomProjection {
16    /// Projection matrix `R ∈ ℝ^{k × d}`.
17    pub components: Array2<f64>,
18    /// Kept rank.
19    pub n_components: usize,
20    /// Input dimension.
21    pub n_features_in: usize,
22    /// Seed used at fit.
23    pub seed: u64,
24}
25
26impl GaussianRandomProjection {
27    /// Fit with the reference `johnson_lindenstrauss_min_dim(n, eps = 0.1)`.
28    pub fn fit(x: ArrayView2<'_, f64>, n_components: usize, seed: u64) -> Result<Self> {
29        let d = x.ncols();
30        if n_components == 0 {
31            return Err(Error::Value("GaussianRandomProjection: n_components must be ≥ 1".into()));
32        }
33        let mut state = seed.wrapping_add(0xF00D_C0DE);
34        let mut r = Array2::<f64>::zeros((n_components, d));
35        let scale = (1.0 / n_components as f64).sqrt();
36        for i in 0..n_components {
37            for j in 0..d {
38                r[[i, j]] = scale * standard_normal(&mut state);
39            }
40        }
41        Ok(Self {
42            components: r,
43            n_components,
44            n_features_in: d,
45            seed,
46        })
47    }
48
49    /// Transform.
50    pub fn transform(&self, x: ArrayView2<'_, f64>) -> Result<Array2<f64>> {
51        if x.ncols() != self.n_features_in {
52            return Err(Error::Shape("GaussianRandomProjection::transform: shape mismatch".into()));
53        }
54        let n = x.nrows();
55        let k = self.n_components;
56        let d = self.n_features_in;
57        let mut out = Array2::<f64>::zeros((n, k));
58        for i in 0..n {
59            for c in 0..k {
60                let mut s = 0.0_f64;
61                for j in 0..d {
62                    s += x[[i, j]] * self.components[[c, j]];
63                }
64                out[[i, c]] = s;
65            }
66        }
67        Ok(out)
68    }
69}
70
71/// SparseRandomProjection — Achlioptas (2003). Entries take one of
72/// `{-√(s/k), 0, +√(s/k)}` with probabilities `{1/2s, 1 − 1/s, 1/2s}`.
73#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
74#[derive(Clone, Debug, PartialEq)]
75pub struct SparseRandomProjection {
76    /// Projection matrix `R`.
77    pub components: Array2<f64>,
78    /// Kept rank.
79    pub n_components: usize,
80    /// Input dimension.
81    pub n_features_in: usize,
82    /// Density `1/s` (fraction of non-zero entries).
83    pub density: f64,
84    /// Seed used at fit.
85    pub seed: u64,
86}
87
88impl SparseRandomProjection {
89    /// Fit with `density = 1/sqrt(n_features)` (the reference default).
90    pub fn fit(x: ArrayView2<'_, f64>, n_components: usize, seed: u64) -> Result<Self> {
91        let d = x.ncols();
92        let density = (1.0_f64 / (d as f64).sqrt()).max(1.0 / d as f64);
93        Self::fit_with(x, n_components, density, seed)
94    }
95
96    /// Full-configuration fit.
97    pub fn fit_with(
98        x: ArrayView2<'_, f64>,
99        n_components: usize,
100        density: f64,
101        seed: u64,
102    ) -> Result<Self> {
103        let d = x.ncols();
104        if n_components == 0 {
105            return Err(Error::Value("SparseRandomProjection: n_components must be ≥ 1".into()));
106        }
107        if !(0.0..=1.0).contains(&density) || density == 0.0 {
108            return Err(Error::Value(format!(
109                "SparseRandomProjection: density must be in (0, 1] (got {density})"
110            )));
111        }
112        let s = 1.0 / density;
113        let scale = (s / n_components as f64).sqrt();
114        let mut state = seed.wrapping_add(0xF00D_D00D);
115        let mut r = Array2::<f64>::zeros((n_components, d));
116        for i in 0..n_components {
117            for j in 0..d {
118                let u = uniform01(&mut state);
119                r[[i, j]] = if u < 1.0 / (2.0 * s) {
120                    -scale
121                } else if u < 1.0 / s {
122                    scale
123                } else {
124                    0.0
125                };
126            }
127        }
128        Ok(Self {
129            components: r,
130            n_components,
131            n_features_in: d,
132            density,
133            seed,
134        })
135    }
136
137    /// Transform.
138    pub fn transform(&self, x: ArrayView2<'_, f64>) -> Result<Array2<f64>> {
139        if x.ncols() != self.n_features_in {
140            return Err(Error::Shape("SparseRandomProjection::transform: shape mismatch".into()));
141        }
142        let n = x.nrows();
143        let k = self.n_components;
144        let d = self.n_features_in;
145        let mut out = Array2::<f64>::zeros((n, k));
146        for i in 0..n {
147            for c in 0..k {
148                let mut s = 0.0_f64;
149                for j in 0..d {
150                    s += x[[i, j]] * self.components[[c, j]];
151                }
152                out[[i, c]] = s;
153            }
154        }
155        Ok(out)
156    }
157}
158
159/// Return the minimum `n_components` such that JL preserves distances
160/// up to `1 ± eps` for `n_samples` points.
161pub fn johnson_lindenstrauss_min_dim(n_samples: usize, eps: f64) -> usize {
162    if eps <= 0.0 || eps >= 1.0 {
163        return 0;
164    }
165    let denom = eps * eps / 2.0 - eps * eps * eps / 3.0;
166    ((4.0 * (n_samples as f64).ln() / denom).ceil() as usize).max(1)
167}
168
169fn standard_normal(state: &mut u64) -> f64 {
170    let u1 = uniform01(state).max(1e-12);
171    let u2 = uniform01(state);
172    (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
173}
174
175fn uniform01(state: &mut u64) -> f64 {
176    *state = state
177        .wrapping_mul(6_364_136_223_846_793_005)
178        .wrapping_add(1_442_695_040_888_963_407);
179    let r = *state >> 11;
180    (r as f64) * f64::from_bits(0x3CA0_0000_0000_0000)
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use ndarray::array;
187
188    #[test]
189    fn gaussian_random_projection_is_deterministic_at_a_seed() {
190        let x = array![[1.0_f64, 2.0, 3.0], [4.0, 5.0, 6.0]];
191        let a = GaussianRandomProjection::fit(x.view(), 5, 42).unwrap();
192        let b = GaussianRandomProjection::fit(x.view(), 5, 42).unwrap();
193        for i in 0..5 {
194            for j in 0..3 {
195                assert_eq!(a.components[[i, j]], b.components[[i, j]]);
196            }
197        }
198    }
199
200    #[test]
201    fn sparse_random_projection_produces_a_projection_with_the_expected_shape() {
202        let x = array![
203            [1.0_f64, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0], [9.0, 10.0, 11.0, 12.0]
204        ];
205        let m = SparseRandomProjection::fit(x.view(), 3, 7).unwrap();
206        let z = m.transform(x.view()).unwrap();
207        assert_eq!(z.shape(), &[3, 3]);
208    }
209
210    #[test]
211    fn jl_min_dim_grows_logarithmically_with_n_samples() {
212        let a = johnson_lindenstrauss_min_dim(100, 0.1);
213        let b = johnson_lindenstrauss_min_dim(10_000, 0.1);
214        // Log-scaling → b/a should be roughly log(100).
215        assert!(b > a);
216        assert!(b < 10 * a);
217    }
218}