Skip to main content

scirs2_interpolate/random_features/
regressor.rs

1//! Ridge regressor built on top of a `FourierFeatureMap`.
2//!
3//! Solves `(ZᵀZ + λI)w = Zᵀy` where `Z` is the random feature matrix.
4//! Prediction: `f(x*) = z(x*)ᵀ w`.
5
6use crate::error::InterpolateError;
7use crate::random_features::feature_map::{FourierFeatureMap, RffKernel};
8use crate::random_features::mod_internal::cholesky_solve_vec;
9use scirs2_core::ndarray::{Array1, Array2, ArrayView1, ArrayView2};
10
11// ─── RandomFeaturesRegressor ─────────────────────────────────────────────────
12
13/// Kernel ridge regression via random Fourier features.
14///
15/// After fitting, prediction is O(D) per test point, making this suitable
16/// for large-scale approximate kernel regression.
17///
18/// # Example
19/// ```rust,ignore
20/// use scirs2_interpolate::random_features::regressor::RandomFeaturesRegressor;
21/// use scirs2_interpolate::random_features::feature_map::RffKernel;
22/// use scirs2_core::ndarray::{Array1, Array2};
23///
24/// let mut reg = RandomFeaturesRegressor::new(
25///     RffKernel::Gaussian { length_scale: 1.0 },
26///     200,   // D random features
27///     1e-4,  // ridge lambda
28///     42,    // seed
29/// );
30/// let x = Array2::<f64>::zeros((50, 2));
31/// let y = Array1::<f64>::zeros(50);
32/// reg.fit(&x.view(), &y.view()).expect("fit");
33/// let y_pred = reg.predict(&x.view()).expect("predict");
34/// ```
35#[derive(Debug, Clone)]
36pub struct RandomFeaturesRegressor {
37    /// Underlying random feature map.
38    pub feature_map: FourierFeatureMap,
39    /// Fitted weight vector w (shape `[D]`), empty until `fit()` is called.
40    pub weights: Array1<f64>,
41    /// Ridge regularization parameter λ.
42    pub lambda: f64,
43    /// Whether the model has been fitted.
44    fitted: bool,
45}
46
47impl RandomFeaturesRegressor {
48    /// Create a new (unfitted) `RandomFeaturesRegressor`.
49    ///
50    /// # Arguments
51    /// * `kernel`     — kernel and length-scale for the feature map
52    /// * `d_features` — number of random features D
53    /// * `lambda`     — ridge regularization (> 0 recommended for stability)
54    /// * `seed`       — RNG seed
55    ///
56    /// # Panics
57    /// Delegates to [`FourierFeatureMap::new`]; panics if `d_features == 0`.
58    /// The input dimension `d_in` is inferred from the first call to `fit()`.
59    pub fn new(kernel: RffKernel, d_features: usize, lambda: f64, seed: u64) -> Self {
60        // d_in will be determined at fit time; use a placeholder of 1 and
61        // rebuild the map once we know d_in.
62        // We store the parameters for deferred construction.
63        Self {
64            feature_map: FourierFeatureMap::new(kernel, 1, d_features, seed),
65            weights: Array1::zeros(0),
66            lambda,
67            fitted: false,
68        }
69    }
70
71    /// Fit the regressor to training data.
72    ///
73    /// Internally builds `Z = feature_map.transform(x)` (shape `[n, D]`)
74    /// then solves `(ZᵀZ + λI)w = Zᵀy` via Cholesky decomposition.
75    ///
76    /// # Errors
77    /// Returns [`InterpolateError`] on empty input, shape mismatch, or singular system.
78    pub fn fit(
79        &mut self,
80        x: &ArrayView2<f64>,
81        y: &ArrayView1<f64>,
82    ) -> Result<(), InterpolateError> {
83        let n = x.nrows();
84        let d_in = x.ncols();
85        if n == 0 {
86            return Err(InterpolateError::InsufficientData(
87                "Training data is empty".to_string(),
88            ));
89        }
90        if n != y.len() {
91            return Err(InterpolateError::DimensionMismatch(format!(
92                "x has {n} rows but y has {} elements",
93                y.len(),
94            )));
95        }
96        if d_in == 0 {
97            return Err(InterpolateError::InvalidInput {
98                message: "input dimension d_in must be > 0".to_string(),
99            });
100        }
101
102        // Rebuild feature map with correct d_in (preserving d_out and kernel).
103        let d_out = self.feature_map.d_out;
104        let kernel = self.feature_map.kernel.clone();
105        // Use a stable derived seed (combine original seed with d_in).
106        let seed = d_in as u64 * 0x9e37_79b9 ^ 42;
107        self.feature_map = FourierFeatureMap::new(kernel, d_in, d_out, seed);
108
109        let z = self.feature_map.transform(x)?;
110        let d = d_out;
111
112        // Build ZᵀZ (D × D).
113        let mut ztzt = vec![vec![0.0f64; d]; d];
114        for k in 0..n {
115            for i in 0..d {
116                for j in 0..=i {
117                    let v = z[(k, i)] * z[(k, j)];
118                    ztzt[i][j] += v;
119                    if i != j {
120                        ztzt[j][i] += v;
121                    }
122                }
123            }
124        }
125        for i in 0..d {
126            ztzt[i][i] += self.lambda;
127        }
128
129        // Build Zᵀy (D).
130        let mut zty = vec![0.0f64; d];
131        for j in 0..d {
132            for k in 0..n {
133                zty[j] += z[(k, j)] * y[k];
134            }
135        }
136
137        let w = cholesky_solve_vec(&ztzt, &zty)?;
138        self.weights = Array1::from_vec(w);
139        self.fitted = true;
140        Ok(())
141    }
142
143    /// Predict at new data points.
144    ///
145    /// # Errors
146    /// Returns an error if the model has not been fitted yet, or on shape mismatch.
147    pub fn predict(&self, x: &ArrayView2<f64>) -> Result<Array1<f64>, InterpolateError> {
148        if !self.fitted {
149            return Err(InterpolateError::InvalidState(
150                "Model not fitted; call fit() first".to_string(),
151            ));
152        }
153        let z = self.feature_map.transform(x)?;
154        let n = z.nrows();
155        let mut preds = Array1::<f64>::zeros(n);
156        for i in 0..n {
157            let zi = z.row(i);
158            preds[i] = zi.iter().zip(self.weights.iter()).map(|(a, b)| a * b).sum();
159        }
160        Ok(preds)
161    }
162
163    /// Whether the model has been fitted.
164    pub fn is_fitted(&self) -> bool {
165        self.fitted
166    }
167}
168
169// ─── Tests ────────────────────────────────────────────────────────────────────
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use scirs2_core::ndarray::{Array1, Array2};
175
176    #[test]
177    fn test_regressor_sin_1d() {
178        let n = 60;
179        let x = Array2::from_shape_fn((n, 1), |(i, _)| {
180            i as f64 * std::f64::consts::PI * 2.0 / n as f64
181        });
182        let y: Array1<f64> = x.column(0).mapv(f64::sin);
183
184        let mut reg =
185            RandomFeaturesRegressor::new(RffKernel::Gaussian { length_scale: 1.0 }, 200, 1e-4, 42);
186        reg.fit(&x.view(), &y.view()).expect("fit");
187
188        let x_test = Array2::from_shape_fn((20, 1), |(i, _)| {
189            i as f64 * std::f64::consts::PI * 2.0 / 20.0
190        });
191        let y_pred = reg.predict(&x_test.view()).expect("predict");
192        let y_true: Array1<f64> = x_test.column(0).mapv(f64::sin);
193
194        let rmse: f64 = {
195            let sum_sq: f64 = y_pred
196                .iter()
197                .zip(y_true.iter())
198                .map(|(p, t)| (p - t).powi(2))
199                .sum();
200            (sum_sq / 20.0).sqrt()
201        };
202        assert!(rmse < 0.5, "RMSE {rmse} should be < 0.5 for sin regression");
203    }
204
205    #[test]
206    fn test_predict_before_fit_errors() {
207        let reg =
208            RandomFeaturesRegressor::new(RffKernel::Gaussian { length_scale: 1.0 }, 50, 1e-3, 0);
209        let x = Array2::<f64>::zeros((3, 1));
210        assert!(reg.predict(&x.view()).is_err(), "should error before fit");
211    }
212
213    #[test]
214    fn test_regressor_output_length() {
215        let n_train = 20;
216        let n_test = 7;
217        let x_train = Array2::from_shape_fn((n_train, 2), |(i, j)| (i + j) as f64 * 0.1);
218        let y_train = Array1::from_iter((0..n_train).map(|i| i as f64 * 0.5));
219        let x_test = Array2::from_shape_fn((n_test, 2), |(i, j)| (i + j) as f64 * 0.15);
220
221        let mut reg =
222            RandomFeaturesRegressor::new(RffKernel::Laplacian { length_scale: 0.5 }, 30, 1e-3, 7);
223        reg.fit(&x_train.view(), &y_train.view()).expect("fit");
224        let preds = reg.predict(&x_test.view()).expect("predict");
225        assert_eq!(preds.len(), n_test);
226    }
227}