Skip to main content

scirs2_interpolate/random_features/
mod.rs

1//! Random Feature RBF interpolation using Rahimi-Recht (2007) random Fourier features.
2//!
3//! Approximates shift-invariant kernels via random Fourier features:
4//!
5//! ```text
6//! k(x, y) ≈ z(x)^T z(y)
7//! z(x) = sqrt(2/D) * [cos(ω_1^T x + b_1), ..., cos(ω_D^T x + b_D)]
8//! ```
9//!
10//! where ω_i are sampled from the spectral density of the kernel.
11//!
12//! # References
13//! - Rahimi, A. & Recht, B. (2007). Random features for large-scale kernel machines. NIPS.
14//! - Yu, F. X. et al. (2016). Orthogonal Random Features. NeurIPS.
15
16// Sub-modules — new ndarray-based API
17pub mod feature_map;
18pub(crate) mod mod_internal;
19pub mod orthogonal;
20pub mod regressor;
21
22// Re-export key public types at the module level for convenience.
23pub use feature_map::{FourierFeatureMap, RffKernel};
24pub use orthogonal::OrthogonalFourierFeatureMap;
25pub use regressor::RandomFeaturesRegressor;
26
27use crate::error::InterpolateError;
28
29// ─── Linear Congruential Generator ─────────────────────────────────────────
30
31/// A seeded LCG pseudo-random number generator (64-bit Knuth multiplicative).
32struct Lcg {
33    state: u64,
34}
35
36impl Lcg {
37    fn new(seed: u64) -> Self {
38        Self {
39            state: seed.wrapping_add(1),
40        }
41    }
42
43    fn next_u64(&mut self) -> u64 {
44        self.state = self
45            .state
46            .wrapping_mul(6_364_136_223_846_793_005)
47            .wrapping_add(1_442_695_040_888_963_407);
48        self.state
49    }
50
51    /// Uniform float in [0, 1)
52    fn next_f64(&mut self) -> f64 {
53        (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
54    }
55
56    /// Standard normal sample via Box-Muller transform.
57    fn next_normal(&mut self) -> f64 {
58        loop {
59            let u1 = self.next_f64();
60            let u2 = self.next_f64();
61            if u1 > 1e-300 {
62                return (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
63            }
64        }
65    }
66
67    /// Cauchy sample: ratio of two standard normals (a/b where b != 0).
68    fn next_cauchy(&mut self) -> f64 {
69        loop {
70            let b = self.next_normal();
71            if b.abs() > 1e-15 {
72                return self.next_normal() / b;
73            }
74        }
75    }
76}
77
78// ─── KernelType ─────────────────────────────────────────────────────────────
79
80/// Type of shift-invariant kernel to approximate.
81#[non_exhaustive]
82#[derive(Debug, Clone, PartialEq)]
83pub enum KernelType {
84    /// Gaussian (RBF) kernel: k(x, y) = exp(-||x-y||² / (2 bw²))
85    Gaussian,
86    /// Laplacian kernel: k(x, y) = exp(-||x-y||₁ / bw)
87    Laplacian,
88    /// Cauchy kernel: k(x, y) = 1 / (1 + ||x-y||² / bw²)
89    Cauchy,
90    /// Matérn 3/2 kernel
91    Matern32,
92    /// Matérn 5/2 kernel
93    Matern52,
94}
95
96// ─── RandomFeatureConfig ────────────────────────────────────────────────────
97
98/// Configuration for the random feature map.
99#[derive(Debug, Clone)]
100pub struct RandomFeatureConfig {
101    /// Number of random features D (approximation quality grows with D).
102    pub n_features: usize,
103    /// Kernel type to approximate.
104    pub kernel: KernelType,
105    /// Bandwidth / length-scale parameter.
106    pub bandwidth: f64,
107    /// Random seed for reproducibility.
108    pub seed: u64,
109}
110
111impl Default for RandomFeatureConfig {
112    fn default() -> Self {
113        Self {
114            n_features: 500,
115            kernel: KernelType::Gaussian,
116            bandwidth: 1.0,
117            seed: 42,
118        }
119    }
120}
121
122// ─── RandomFeatureMap ────────────────────────────────────────────────────────
123
124/// Random Fourier feature map z: R^d → R^(2D).
125///
126/// Approximates a shift-invariant kernel via:
127/// ```text
128/// z(x) = sqrt(2/D) * [cos(ω_1^T x + b_1), ..., cos(ω_D^T x + b_D)]
129/// ```
130#[derive(Debug, Clone)]
131pub struct RandomFeatureMap {
132    /// Frequency vectors, shape `[n_features][n_dims]`.
133    pub weights: Vec<Vec<f64>>,
134    /// Phase offsets, shape `[n_features]`.
135    pub biases: Vec<f64>,
136    /// Configuration used to create this map.
137    pub config: RandomFeatureConfig,
138    /// Number of input dimensions.
139    n_dims: usize,
140}
141
142impl RandomFeatureMap {
143    /// Create a new random feature map for the given number of input dimensions.
144    pub fn new(n_dims: usize, config: RandomFeatureConfig) -> Result<Self, InterpolateError> {
145        if n_dims == 0 {
146            return Err(InterpolateError::InvalidInput {
147                message: "n_dims must be > 0".to_string(),
148            });
149        }
150        if config.n_features == 0 {
151            return Err(InterpolateError::InvalidInput {
152                message: "n_features must be > 0".to_string(),
153            });
154        }
155        if config.bandwidth <= 0.0 {
156            return Err(InterpolateError::InvalidInput {
157                message: "bandwidth must be positive".to_string(),
158            });
159        }
160
161        let mut rng = Lcg::new(config.seed);
162        let d = config.n_features;
163        let bw = config.bandwidth;
164
165        let weights: Vec<Vec<f64>> = (0..d)
166            .map(|_| {
167                (0..n_dims)
168                    .map(|_| match config.kernel {
169                        KernelType::Gaussian => rng.next_normal() / bw,
170                        KernelType::Laplacian | KernelType::Cauchy => rng.next_cauchy() / bw,
171                        KernelType::Matern32 => {
172                            // Matérn 3/2 spectral density ~ Student-t ν=3: sample as
173                            // Normal / sqrt(Chi²(3)/3). Approximate via ratio method.
174                            let g = rng.next_normal() / bw;
175                            // Scale by chi factor for ν=3
176                            let chi = {
177                                let s: f64 = (0..3).map(|_| rng.next_normal().powi(2)).sum();
178                                (s / 3.0).sqrt()
179                            };
180                            g / chi.max(1e-12)
181                        }
182                        KernelType::Matern52 => {
183                            // Matérn 5/2 spectral density ~ Student-t ν=5
184                            let g = rng.next_normal() / bw;
185                            let chi = {
186                                let s: f64 = (0..5).map(|_| rng.next_normal().powi(2)).sum();
187                                (s / 5.0).sqrt()
188                            };
189                            g / chi.max(1e-12)
190                        }
191                    })
192                    .collect()
193            })
194            .collect();
195
196        let biases: Vec<f64> = (0..d)
197            .map(|_| rng.next_f64() * 2.0 * std::f64::consts::PI)
198            .collect();
199
200        Ok(Self {
201            weights,
202            biases,
203            config,
204            n_dims,
205        })
206    }
207
208    /// Map input points `x` (shape `[n_samples][n_dims]`) to features
209    /// of shape `[n_samples][n_features]`.
210    ///
211    /// Feature formula: `z_i(x) = sqrt(2/D) * cos(ω_i^T x + b_i)`.
212    pub fn transform(&self, x: &[Vec<f64>]) -> Result<Vec<Vec<f64>>, InterpolateError> {
213        if x.is_empty() {
214            return Ok(Vec::new());
215        }
216        let n = x.len();
217        let d = self.config.n_features;
218        let scale = (2.0 / d as f64).sqrt();
219
220        let mut z = vec![vec![0.0f64; d]; n];
221
222        for (i, xi) in x.iter().enumerate() {
223            if xi.len() != self.n_dims {
224                return Err(InterpolateError::DimensionMismatch(format!(
225                    "Expected {} dimensions, got {}",
226                    self.n_dims,
227                    xi.len()
228                )));
229            }
230            for j in 0..d {
231                let dot: f64 = self.weights[j]
232                    .iter()
233                    .zip(xi.iter())
234                    .map(|(w, xv)| w * xv)
235                    .sum();
236                z[i][j] = scale * (dot + self.biases[j]).cos();
237            }
238        }
239
240        Ok(z)
241    }
242
243    /// Approximate kernel value k(x1, x2) ≈ z(x1)^T z(x2).
244    pub fn kernel_approx(&self, x1: &[f64], x2: &[f64]) -> Result<f64, InterpolateError> {
245        if x1.len() != self.n_dims || x2.len() != self.n_dims {
246            return Err(InterpolateError::DimensionMismatch(format!(
247                "Expected {} dimensions",
248                self.n_dims
249            )));
250        }
251        let scale = 2.0 / self.config.n_features as f64;
252        let mut result = 0.0f64;
253        for j in 0..self.config.n_features {
254            let dot1: f64 = self.weights[j]
255                .iter()
256                .zip(x1.iter())
257                .map(|(w, xv)| w * xv)
258                .sum();
259            let dot2: f64 = self.weights[j]
260                .iter()
261                .zip(x2.iter())
262                .map(|(w, xv)| w * xv)
263                .sum();
264            result += (dot1 + self.biases[j]).cos() * (dot2 + self.biases[j]).cos();
265        }
266        Ok(scale * result)
267    }
268}
269
270// ─── RandomFeatureInterpolator ───────────────────────────────────────────────
271
272/// Kernel ridge regression using random Fourier features.
273///
274/// Solves: `(Z^T Z + λ I) w = Z^T y` where Z is the random feature matrix.
275/// Prediction: `f(x*) = z(x*)^T w`.
276#[derive(Debug, Clone)]
277pub struct RandomFeatureInterpolator {
278    /// The underlying random feature map.
279    pub feature_map: RandomFeatureMap,
280    /// Solved weight vector, shape `[n_features]`.
281    pub weights: Vec<f64>,
282    /// Ridge regularization parameter λ.
283    pub regularization: f64,
284    /// Whether the model has been fitted.
285    fitted: bool,
286}
287
288impl RandomFeatureInterpolator {
289    /// Create a new (unfitted) interpolator.
290    pub fn new(
291        n_dims: usize,
292        config: RandomFeatureConfig,
293        regularization: f64,
294    ) -> Result<Self, InterpolateError> {
295        let feature_map = RandomFeatureMap::new(n_dims, config)?;
296        Ok(Self {
297            feature_map,
298            weights: Vec::new(),
299            regularization,
300            fitted: false,
301        })
302    }
303
304    /// Fit to training data `(x, y)`.
305    ///
306    /// Solves `(Z^T Z + λ I) w = Z^T y` via Cholesky decomposition.
307    pub fn fit(&mut self, x: &[Vec<f64>], y: &[f64]) -> Result<(), InterpolateError> {
308        if x.is_empty() {
309            return Err(InterpolateError::InsufficientData(
310                "Training data is empty".to_string(),
311            ));
312        }
313        if x.len() != y.len() {
314            return Err(InterpolateError::DimensionMismatch(format!(
315                "x has {} rows but y has {} elements",
316                x.len(),
317                y.len()
318            )));
319        }
320
321        let z = self.feature_map.transform(x)?;
322        let n = z.len();
323        let d = self.feature_map.config.n_features;
324
325        // Build Z^T Z  (d × d)
326        let mut ztzt = vec![vec![0.0f64; d]; d];
327        for i in 0..d {
328            for j in 0..=i {
329                let val: f64 = (0..n).map(|k| z[k][i] * z[k][j]).sum();
330                ztzt[i][j] = val;
331                ztzt[j][i] = val;
332            }
333            ztzt[i][i] += self.regularization;
334        }
335
336        // Build Z^T y  (d)
337        let mut zty = vec![0.0f64; d];
338        for j in 0..d {
339            zty[j] = (0..n).map(|k| z[k][j] * y[k]).sum();
340        }
341
342        // Solve via Cholesky: (Z^T Z + λI) w = Z^T y
343        self.weights = cholesky_solve(&ztzt, &zty)?;
344        self.fitted = true;
345        Ok(())
346    }
347
348    /// Predict at new points.
349    pub fn predict(&self, x: &[Vec<f64>]) -> Result<Vec<f64>, InterpolateError> {
350        if !self.fitted {
351            return Err(InterpolateError::InvalidState(
352                "Model not fitted yet, call fit() first".to_string(),
353            ));
354        }
355        let z = self.feature_map.transform(x)?;
356        let preds: Vec<f64> = z
357            .iter()
358            .map(|zi| zi.iter().zip(self.weights.iter()).map(|(a, b)| a * b).sum())
359            .collect();
360        Ok(preds)
361    }
362
363    /// Compute mean absolute error between the random feature kernel approximation
364    /// and the true kernel evaluated on all pairs in `x`.
365    pub fn kernel_error(
366        &self,
367        x: &[Vec<f64>],
368        true_kernel_fn: impl Fn(&[f64], &[f64]) -> f64,
369    ) -> Result<f64, InterpolateError> {
370        let n = x.len();
371        if n == 0 {
372            return Ok(0.0);
373        }
374        let mut total_err = 0.0f64;
375        let mut count = 0usize;
376        for i in 0..n {
377            for j in 0..n {
378                let approx = self.feature_map.kernel_approx(&x[i], &x[j])?;
379                let exact = true_kernel_fn(&x[i], &x[j]);
380                total_err += (approx - exact).abs();
381                count += 1;
382            }
383        }
384        if count == 0 {
385            Ok(0.0)
386        } else {
387            Ok(total_err / count as f64)
388        }
389    }
390}
391
392// ─── Cholesky solver ─────────────────────────────────────────────────────────
393
394/// Solve `A x = b` for a symmetric positive definite `A` via Cholesky decomposition.
395/// Uses in-place lower triangular factoring.
396pub(crate) fn cholesky_solve(a: &[Vec<f64>], b: &[f64]) -> Result<Vec<f64>, InterpolateError> {
397    let n = a.len();
398    if n == 0 {
399        return Ok(Vec::new());
400    }
401
402    // Compute lower Cholesky factor L in-place
403    let mut l = vec![vec![0.0f64; n]; n];
404    for i in 0..n {
405        for j in 0..=i {
406            let mut s: f64 = a[i][j];
407            for k in 0..j {
408                s -= l[i][k] * l[j][k];
409            }
410            if i == j {
411                if s < 0.0 {
412                    // Fallback: use diagonal regularization and retry
413                    return cholesky_solve_fallback(a, b);
414                }
415                l[i][j] = s.sqrt().max(1e-300);
416            } else {
417                l[i][j] = s / l[j][j].max(1e-300);
418            }
419        }
420    }
421
422    // Forward substitution: L y = b
423    let mut y = vec![0.0f64; n];
424    for i in 0..n {
425        let mut s = b[i];
426        for k in 0..i {
427            s -= l[i][k] * y[k];
428        }
429        y[i] = s / l[i][i].max(1e-300);
430    }
431
432    // Backward substitution: L^T x = y
433    let mut x = vec![0.0f64; n];
434    for i in (0..n).rev() {
435        let mut s = y[i];
436        for k in (i + 1)..n {
437            s -= l[k][i] * x[k];
438        }
439        x[i] = s / l[i][i].max(1e-300);
440    }
441
442    Ok(x)
443}
444
445/// Fallback Cholesky using conjugate gradient for near-singular systems.
446fn cholesky_solve_fallback(a: &[Vec<f64>], b: &[f64]) -> Result<Vec<f64>, InterpolateError> {
447    // Use conjugate gradient method
448    let n = a.len();
449    let mut x = vec![0.0f64; n];
450    let mut r = b.to_vec();
451    let mut p = r.clone();
452    let mut rs_old: f64 = r.iter().map(|v| v * v).sum();
453
454    for _ in 0..(n * 10) {
455        // ap = A * p
456        let ap: Vec<f64> = (0..n)
457            .map(|i| (0..n).map(|j| a[i][j] * p[j]).sum())
458            .collect();
459        let pap: f64 = p.iter().zip(ap.iter()).map(|(a, b)| a * b).sum();
460        if pap.abs() < 1e-300 {
461            break;
462        }
463        let alpha = rs_old / pap;
464        for i in 0..n {
465            x[i] += alpha * p[i];
466            r[i] -= alpha * ap[i];
467        }
468        let rs_new: f64 = r.iter().map(|v| v * v).sum();
469        if rs_new.sqrt() < 1e-10 {
470            break;
471        }
472        let beta = rs_new / rs_old;
473        for i in 0..n {
474            p[i] = r[i] + beta * p[i];
475        }
476        rs_old = rs_new;
477    }
478
479    Ok(x)
480}
481
482// ─── Tests ───────────────────────────────────────────────────────────────────
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    fn gaussian_kernel(bw: f64, x1: &[f64], x2: &[f64]) -> f64 {
489        let sq: f64 = x1.iter().zip(x2.iter()).map(|(a, b)| (a - b).powi(2)).sum();
490        (-sq / (2.0 * bw * bw)).exp()
491    }
492
493    #[test]
494    fn test_gaussian_kernel_approximation_error() {
495        let config = RandomFeatureConfig {
496            n_features: 200,
497            kernel: KernelType::Gaussian,
498            bandwidth: 1.0,
499            seed: 12345,
500        };
501        let map = RandomFeatureMap::new(2, config).expect("should create map");
502
503        // Test on a small grid
504        let xs: Vec<Vec<f64>> = (0..5)
505            .flat_map(|i| (0..5).map(move |j| vec![i as f64 * 0.5, j as f64 * 0.5]))
506            .collect();
507
508        let mut total_err = 0.0f64;
509        let mut count = 0usize;
510        let bw = 1.0;
511        for x1 in &xs {
512            for x2 in &xs {
513                let approx = map.kernel_approx(x1, x2).expect("kernel approx");
514                let exact = gaussian_kernel(bw, x1, x2);
515                total_err += (approx - exact).abs();
516                count += 1;
517            }
518        }
519        let mean_err = total_err / count as f64;
520        assert!(
521            mean_err < 0.15,
522            "Mean kernel approximation error too large: {mean_err}"
523        );
524    }
525
526    #[test]
527    fn test_fit_predict_sin() {
528        // 1D sin function: wrap in Vec<Vec<f64>>
529        let x_train: Vec<Vec<f64>> = (0..30)
530            .map(|i| vec![i as f64 * 2.0 * std::f64::consts::PI / 30.0])
531            .collect();
532        let y_train: Vec<f64> = x_train.iter().map(|xi| xi[0].sin()).collect();
533
534        let config = RandomFeatureConfig {
535            n_features: 100,
536            kernel: KernelType::Gaussian,
537            bandwidth: 1.0,
538            seed: 999,
539        };
540        let mut interp =
541            RandomFeatureInterpolator::new(1, config, 1e-4).expect("create interpolator");
542        interp.fit(&x_train, &y_train).expect("fit");
543
544        let x_test: Vec<Vec<f64>> = (0..10)
545            .map(|i| vec![i as f64 * 2.0 * std::f64::consts::PI / 10.0])
546            .collect();
547        let preds = interp.predict(&x_test).expect("predict");
548        assert_eq!(preds.len(), 10, "Prediction count mismatch");
549    }
550
551    #[test]
552    fn test_predict_shape_correct() {
553        let n = 15;
554        let x: Vec<Vec<f64>> = (0..n).map(|i| vec![i as f64 * 0.1]).collect();
555        let y: Vec<f64> = x.iter().map(|xi| xi[0] * 2.0 + 1.0).collect();
556
557        let config = RandomFeatureConfig {
558            n_features: 50,
559            kernel: KernelType::Laplacian,
560            bandwidth: 0.5,
561            seed: 7,
562        };
563        let mut interp = RandomFeatureInterpolator::new(1, config, 1e-3).expect("create");
564        interp.fit(&x, &y).expect("fit");
565
566        let x_new: Vec<Vec<f64>> = (0..7).map(|i| vec![i as f64 * 0.15]).collect();
567        let preds = interp.predict(&x_new).expect("predict");
568        assert_eq!(preds.len(), 7);
569    }
570
571    #[test]
572    fn test_kernel_types() {
573        for kernel in [
574            KernelType::Gaussian,
575            KernelType::Laplacian,
576            KernelType::Cauchy,
577            KernelType::Matern32,
578            KernelType::Matern52,
579        ] {
580            let config = RandomFeatureConfig {
581                n_features: 50,
582                kernel,
583                bandwidth: 1.0,
584                seed: 1,
585            };
586            let map = RandomFeatureMap::new(2, config).expect("create");
587            let x1 = vec![0.0, 0.0];
588            let x2 = vec![1.0, 1.0];
589            let k = map.kernel_approx(&x1, &x2).expect("kernel approx");
590            assert!(k.is_finite(), "Kernel value should be finite");
591        }
592    }
593
594    #[test]
595    fn test_kernel_error_gaussian() {
596        let config = RandomFeatureConfig {
597            n_features: 300,
598            kernel: KernelType::Gaussian,
599            bandwidth: 1.0,
600            seed: 42,
601        };
602        let x_train: Vec<Vec<f64>> = (0..5)
603            .map(|i| vec![i as f64 * 0.4, i as f64 * 0.2])
604            .collect();
605        let y_train: Vec<f64> = x_train.iter().map(|xi| xi[0].sin()).collect();
606
607        let mut interp = RandomFeatureInterpolator::new(2, config, 1e-4).expect("create");
608        interp.fit(&x_train, &y_train).expect("fit");
609
610        let bw = 1.0f64;
611        let err = interp
612            .kernel_error(&x_train, |x1, x2| {
613                let sq: f64 = x1.iter().zip(x2.iter()).map(|(a, b)| (a - b).powi(2)).sum();
614                (-sq / (2.0 * bw * bw)).exp()
615            })
616            .expect("kernel error");
617        assert!(err < 0.15, "Kernel error too large: {err}");
618    }
619}