Skip to main content

scirs2_interpolate/random_features/
feature_map.rs

1//! Fourier feature maps for shift-invariant kernel approximation.
2//!
3//! Implements Rahimi & Recht (2007) random Fourier features (RFF):
4//! `z(x) = sqrt(2/D) * [cos(ω_1^T x + b_1), ..., cos(ω_D^T x + b_D)]`
5//! where ω_i are sampled from the spectral density of the target kernel.
6//!
7//! # References
8//! - Rahimi, A. & Recht, B. (2007). Random features for large-scale kernel machines. NIPS.
9//! - Yu, F. X. et al. (2016). Orthogonal Random Features. NeurIPS.
10
11use crate::error::InterpolateError;
12use scirs2_core::ndarray::{Array1, Array2, ArrayView2};
13
14// ─── Internal RNG ────────────────────────────────────────────────────────────
15
16/// Linear Congruential Generator (Knuth 64-bit multiplicative).
17/// Exported for use in sibling modules.
18pub(super) struct Lcg64 {
19    state: u64,
20}
21
22impl Lcg64 {
23    pub(super) fn new(seed: u64) -> Self {
24        // Ensure non-zero state to avoid degenerate cycle.
25        Self {
26            state: seed.wrapping_add(1).max(1),
27        }
28    }
29
30    pub(super) fn next_u64(&mut self) -> u64 {
31        self.state = self
32            .state
33            .wrapping_mul(6_364_136_223_846_793_005)
34            .wrapping_add(1_442_695_040_888_963_407);
35        self.state
36    }
37
38    /// Uniform float in [0, 1).
39    pub(super) fn next_f64(&mut self) -> f64 {
40        (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
41    }
42
43    /// Standard normal via Box-Muller transform.
44    pub(super) fn next_normal(&mut self) -> f64 {
45        loop {
46            let u1 = self.next_f64();
47            if u1 > 1e-300 {
48                let u2 = self.next_f64();
49                return (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
50            }
51        }
52    }
53
54    /// Cauchy sample: ratio of two normals.
55    pub(super) fn next_cauchy(&mut self) -> f64 {
56        loop {
57            let b = self.next_normal();
58            if b.abs() > 1e-15 {
59                return self.next_normal() / b;
60            }
61        }
62    }
63
64    /// Student-t sample with `nu` degrees of freedom.
65    /// Uses: t(ν) = Z / sqrt(χ²(ν)/ν) where Z ~ N(0,1) and χ²(ν) = sum of ν normals squared.
66    pub(super) fn next_student_t(&mut self, nu: usize) -> f64 {
67        let z = self.next_normal();
68        let chi2: f64 = (0..nu).map(|_| self.next_normal().powi(2)).sum();
69        let chi = (chi2 / nu as f64).sqrt().max(1e-300);
70        z / chi
71    }
72}
73
74// ─── RffKernel ───────────────────────────────────────────────────────────────
75
76/// Shift-invariant kernel type for Random Fourier Feature approximation.
77#[non_exhaustive]
78#[derive(Debug, Clone, PartialEq)]
79pub enum RffKernel {
80    /// Gaussian (RBF) kernel: K(x,y) = exp(-||x-y||²/(2l²)).
81    /// Spectral density: p(ω) = N(0, I/l²).
82    Gaussian {
83        /// Length-scale parameter l > 0.
84        length_scale: f64,
85    },
86    /// Laplacian kernel: K(x,y) = exp(-||x-y||₁/l).
87    /// Spectral density: product of Cauchy distributions scaled by 1/l.
88    Laplacian {
89        /// Length-scale parameter l > 0.
90        length_scale: f64,
91    },
92    /// Matérn 3/2 kernel.
93    /// Spectral density: scaled Student-t with ν=3 degrees of freedom.
94    Matern32 {
95        /// Length-scale parameter l > 0.
96        length_scale: f64,
97    },
98    /// Matérn 5/2 kernel.
99    /// Spectral density: scaled Student-t with ν=5 degrees of freedom.
100    Matern52 {
101        /// Length-scale parameter l > 0.
102        length_scale: f64,
103    },
104}
105
106impl RffKernel {
107    /// Length-scale for this kernel variant.
108    pub fn length_scale(&self) -> f64 {
109        match self {
110            RffKernel::Gaussian { length_scale }
111            | RffKernel::Laplacian { length_scale }
112            | RffKernel::Matern32 { length_scale }
113            | RffKernel::Matern52 { length_scale } => *length_scale,
114        }
115    }
116
117    /// Sample one frequency component ω_k (scalar for one input dimension) from
118    /// the spectral density of this kernel.
119    pub(super) fn sample_omega_component(&self, rng: &mut Lcg64) -> f64 {
120        let ls = self.length_scale().max(1e-300);
121        match self {
122            RffKernel::Gaussian { .. } => rng.next_normal() / ls,
123            RffKernel::Laplacian { .. } => rng.next_cauchy() / ls,
124            RffKernel::Matern32 { .. } => rng.next_student_t(3) / ls,
125            RffKernel::Matern52 { .. } => rng.next_student_t(5) / ls,
126        }
127    }
128}
129
130// ─── FourierFeatureMap ───────────────────────────────────────────────────────
131
132/// Random Fourier Feature (RFF) map using the Rahimi-Recht construction.
133///
134/// Maps `x ∈ R^d_in` to `z(x) ∈ R^d_out` via:
135/// `z(x)_j = sqrt(2/D) * cos(Ω_j · x + b_j)`,
136/// where `Ω ∈ R^(D×d)` and `b ∈ R^D` are drawn at construction time.
137///
138/// Then `E[z(x)ᵀz(y)] = K(x, y)` for the target kernel K.
139///
140/// # Example
141/// ```rust,ignore
142/// use scirs2_interpolate::random_features::feature_map::{FourierFeatureMap, RffKernel};
143/// use scirs2_core::ndarray::Array2;
144///
145/// let map = FourierFeatureMap::new(
146///     RffKernel::Gaussian { length_scale: 1.0 },
147///     2,   // input dimensions
148///     500, // output features D
149///     42,  // seed
150/// );
151/// let x = Array2::zeros((10, 2));
152/// let z = map.transform(&x.view()); // shape (10, 500)
153/// ```
154#[derive(Debug, Clone)]
155pub struct FourierFeatureMap {
156    /// Frequency matrix, shape `[D, d_in]`.
157    omega: Array2<f64>,
158    /// Phase bias vector, shape `[D]`.
159    bias: Array1<f64>,
160    /// The kernel this map approximates.
161    pub kernel: RffKernel,
162    /// `sqrt(2/D)` scaling factor.
163    scale: f64,
164    /// Number of input dimensions.
165    pub d_in: usize,
166    /// Number of output features (D).
167    pub d_out: usize,
168}
169
170impl FourierFeatureMap {
171    /// Construct a `FourierFeatureMap` from pre-computed matrices.
172    ///
173    /// Used internally by [`crate::random_features::orthogonal::OrthogonalFourierFeatureMap`]
174    /// to inject an orthogonalised frequency matrix without re-sampling.
175    pub(super) fn from_parts(
176        kernel: RffKernel,
177        d_in: usize,
178        d_out: usize,
179        omega: Array2<f64>,
180        bias: Array1<f64>,
181        scale: f64,
182    ) -> Self {
183        Self {
184            omega,
185            bias,
186            kernel,
187            scale,
188            d_in,
189            d_out,
190        }
191    }
192
193    /// Construct a new `FourierFeatureMap`.
194    ///
195    /// # Arguments
196    /// * `kernel` — kernel type and length-scale
197    /// * `d_in`   — number of input dimensions
198    /// * `d_out`  — number of random features D (larger = better approximation)
199    /// * `seed`   — RNG seed for reproducibility
200    ///
201    /// # Panics
202    /// Panics if `d_in == 0` or `d_out == 0`.
203    pub fn new(kernel: RffKernel, d_in: usize, d_out: usize, seed: u64) -> Self {
204        assert!(d_in > 0, "d_in must be > 0");
205        assert!(d_out > 0, "d_out must be > 0");
206
207        let mut rng = Lcg64::new(seed);
208
209        // Sample Ω (D × d_in)
210        let omega_data: Vec<f64> = (0..d_out * d_in)
211            .map(|_| kernel.sample_omega_component(&mut rng))
212            .collect();
213        let omega = Array2::from_shape_vec((d_out, d_in), omega_data)
214            .expect("shape is consistent by construction");
215
216        // Sample b ~ Uniform[0, 2π]
217        let bias_data: Vec<f64> = (0..d_out)
218            .map(|_| rng.next_f64() * 2.0 * std::f64::consts::PI)
219            .collect();
220        let bias = Array1::from_vec(bias_data);
221
222        let scale = (2.0 / d_out as f64).sqrt();
223
224        Self {
225            omega,
226            bias,
227            kernel,
228            scale,
229            d_in,
230            d_out,
231        }
232    }
233
234    /// Transform input `x` (shape `[n, d_in]`) into features (shape `[n, D]`).
235    ///
236    /// `z[i, j] = scale * cos(Ω[j, :] · x[i, :] + b[j])`
237    ///
238    /// # Errors
239    /// Returns an error if `x.ncols() != self.d_in`.
240    pub fn transform(&self, x: &ArrayView2<f64>) -> Result<Array2<f64>, InterpolateError> {
241        let n = x.nrows();
242        let d = x.ncols();
243        if d != self.d_in {
244            return Err(InterpolateError::DimensionMismatch(format!(
245                "FourierFeatureMap expects {d_in} input dimensions, got {d}",
246                d_in = self.d_in,
247            )));
248        }
249
250        let mut z = Array2::<f64>::zeros((n, self.d_out));
251        for i in 0..n {
252            let xi = x.row(i);
253            for j in 0..self.d_out {
254                let omega_j = self.omega.row(j);
255                let dot: f64 = omega_j.iter().zip(xi.iter()).map(|(w, xv)| w * xv).sum();
256                z[(i, j)] = self.scale * (dot + self.bias[j]).cos();
257            }
258        }
259        Ok(z)
260    }
261
262    /// Approximate the kernel value K(x1, x2) ≈ z(x1)ᵀz(y2).
263    ///
264    /// # Errors
265    /// Returns an error if either slice has length != `d_in`.
266    pub fn kernel_approx(&self, x1: &[f64], x2: &[f64]) -> Result<f64, InterpolateError> {
267        if x1.len() != self.d_in || x2.len() != self.d_in {
268            return Err(InterpolateError::DimensionMismatch(format!(
269                "kernel_approx expects {d_in} dimensions",
270                d_in = self.d_in,
271            )));
272        }
273        let mut result = 0.0f64;
274        for j in 0..self.d_out {
275            let omega_j = self.omega.row(j);
276            let dot1: f64 = omega_j.iter().zip(x1.iter()).map(|(w, v)| w * v).sum();
277            let dot2: f64 = omega_j.iter().zip(x2.iter()).map(|(w, v)| w * v).sum();
278            result += (dot1 + self.bias[j]).cos() * (dot2 + self.bias[j]).cos();
279        }
280        Ok(2.0 / self.d_out as f64 * result)
281    }
282
283    /// Access the frequency matrix Ω (shape `[D, d_in]`).
284    pub fn omega(&self) -> &Array2<f64> {
285        &self.omega
286    }
287
288    /// Access the bias vector b (shape `[D]`).
289    pub fn bias(&self) -> &Array1<f64> {
290        &self.bias
291    }
292}
293
294// ─── Tests ────────────────────────────────────────────────────────────────────
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use scirs2_core::ndarray::Array2;
300
301    #[test]
302    fn test_rff_output_shape() {
303        let map = FourierFeatureMap::new(RffKernel::Gaussian { length_scale: 1.0 }, 3, 64, 1);
304        let x = Array2::<f64>::zeros((5, 3));
305        let z = map.transform(&x.view()).expect("transform");
306        assert_eq!(z.shape(), &[5, 64]);
307    }
308
309    #[test]
310    fn test_gaussian_kernel_approx_close() {
311        // For D=1000, E[z(x)ᵀz(y)] ≈ exp(-||x-y||²/2)
312        let map = FourierFeatureMap::new(RffKernel::Gaussian { length_scale: 1.0 }, 2, 1000, 77);
313        let x1 = [1.0f64, 0.0];
314        let x2 = [0.0f64, 1.0];
315        let true_k = (-1.0f64).exp(); // exp(-||x1-x2||²/(2·1²)) = exp(-1)
316        let approx_k = map.kernel_approx(&x1, &x2).expect("approx");
317        let err = (approx_k - true_k).abs();
318        assert!(err < 0.1, "error={err:.4}, expected < 0.1 for D=1000");
319    }
320
321    #[test]
322    fn test_all_kernel_types_output_finite() {
323        let x = Array2::from_shape_fn((4, 2), |(i, j)| (i + j) as f64 * 0.2);
324        for kernel in [
325            RffKernel::Gaussian { length_scale: 1.0 },
326            RffKernel::Laplacian { length_scale: 1.0 },
327            RffKernel::Matern32 { length_scale: 1.0 },
328            RffKernel::Matern52 { length_scale: 1.0 },
329        ] {
330            let map = FourierFeatureMap::new(kernel, 2, 32, 0);
331            let z = map.transform(&x.view()).expect("transform");
332            assert!(
333                z.iter().all(|v| v.is_finite()),
334                "all features must be finite"
335            );
336        }
337    }
338}