Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Using ndarray, Option, and error handling

This section introduces several important features of the language:

  • Using the ndarray crate for numerical arrays
  • Representing optional values with Option, Some, and None
  • Using pattern matching with match
  • Handling errors using Box<dyn Error> and .into()
  • Automatically deriving common trait implementations using #[derive(...)]

Motivation

In previous sections, we worked with Vec<f64> and returned plain values. In practice, we might need:

  • Efficient linear algebra tools, provided by external crates such as ndarray and nalgebra
  • A way to represent "fitted" or "not fitted" states, using Option<f64>
  • A way to return errors when something goes wrong, using Result<_, _>>
  • Automatically implementing traits like Debug, Clone, and Default to simplify testing, debugging, and construction

We combine these in the implementation of the analytical RidgeEstimator. You can have a look to the full code below before we go through the main features step by step.

The full code : regressor.rs
#![allow(unused)]
fn main() {
use ndarray::Array1;

/// A Ridge regression estimator using `ndarray` for vectorized operations.
///
/// This version supports fitting and predicting using `Array1<f64>` arrays.
/// The coefficient `beta` is stored as an `Option<f64>`, allowing the model
/// to represent both fitted and unfitted states.
// ANCHOR: struct
#[derive(Debug, Clone, Default)]
pub struct RidgeEstimator {
    pub beta: Option<f64>,
}
// ANCHOR_END: struct

// ANCHOR: ridge_estimator_impl_new_fit
impl RidgeEstimator {
    /// Creates a new, unfitted Ridge estimator.
    ///
    /// # Returns
    /// A `RidgeEstimator` with `beta` set to `None`.
    pub fn new() -> Self {
        Self { beta: None }
    }

    /// Fits the Ridge regression model using 1D input and output arrays.
    ///
    /// This function computes the coefficient `beta` using the closed-form
    /// solution with L2 regularization.
    ///
    /// # Arguments
    /// - `x`: Input features as a 1D `Array1<f64>`.
    /// - `y`: Target values as a 1D `Array1<f64>`.
    /// - `lambda2`: The regularization strength (λ²).
    pub fn fit(&mut self, x: &Array1<f64>, y: &Array1<f64>, lambda2: f64) {
        let n: usize = x.len();
        assert!(n > 0);
        assert_eq!(x.len(), y.len(), "x and y must have the same length");

        // mean returns None if the array is empty, so we need to unwrap it
        let x_mean: f64 = x.mean().unwrap();
        let y_mean: f64 = y.mean().unwrap();

        let num: f64 = (x - x_mean).dot(&(y - y_mean));
        let denom: f64 = (x - x_mean).mapv(|z| z.powi(2)).sum() + lambda2 * (n as f64);

        self.beta = Some(num / denom);
    }
}
// ANCHOR_END: ridge_estimator_impl_new_fit

// ANCHOR: ridge_estimator_impl_predict
impl RidgeEstimator {
    /// Predicts target values given input features.
    ///
    /// # Arguments
    /// - `x`: Input features as a 1D array.
    ///
    /// # Returns
    /// A `Result` containing the predicted values, or an error if the model
    /// has not been fitted.
    pub fn predict(&self, x: &Array1<f64>) -> Result<Array1<f64>, String> {
        match &self.beta {
            Some(beta) => Ok(*beta * x),
            None => Err("Model not fitted".to_string()),
        }
    }
}
// ANCHOR_END: ridge_estimator_impl_predict

// ANCHOR: tests
#[cfg(test)]
mod tests {
    use super::*;
    use ndarray::array;

    #[test]
    fn test_ridge_estimator_constructor() {
        let model = RidgeEstimator::new();
        assert_eq!(model.beta, None, "beta is expected to be None");
    }

    #[test]
    fn test_unfitted_estimator() {
        let model = RidgeEstimator::new();
        let x: Array1<f64> = array![1.0, 2.0];
        let result: Result<Array1<f64>, String> = model.predict(&x);

        assert!(result.is_err());
        assert_eq!(result.unwrap_err(), "Model not fitted");
    }

    #[test]
    fn test_ridge_estimator_solution() {
        let x: Array1<f64> = array![1.0, 2.0];
        let y: Array1<f64> = array![0.1, 0.2];
        let true_beta: f64 = 0.1;
        let lambda2: f64 = 0.0;

        let mut model = RidgeEstimator::new();
        model.fit(&x, &y, lambda2);

        assert!(model.beta.is_some(), "beta is expected to be Some(f64)");

        assert!(
            (true_beta - model.beta.unwrap()).abs() < 1e-6,
            "Estimate {} not close enough to true solution {}",
            true_beta,
            model.beta.unwrap()
        );
    }
}
// ANCHOR_END: tests
}

What we're building here

The aim of this chapter is to build a small crate with the following layout:

crates/ridge_1d_ndarray/
├── Cargo.toml
└── src
    ├── regressor.rs    # Closed-form solution of the Ridge estimator
    └── lib.rs          # Main entry point for the library

Again, the module regressor.rs implements a RidgeEstimator type. We end up with the following user interface:

#![allow(unused)]
fn main() {
use ndarray::array;
use regressor::RidgeEstimator;

let mut model = RidgeEstimator::new();

let x = array![1.0, 2.0];
let y = array![0.1, 0.2];
let lambda2 = 0.001;

model.fit(&x, &y, lambda2);
let preds = model.predict(&x);

match model.beta {
    Some(beta) => println!("Learned beta: {beta}, true solution: 0.1!"),
    None => println!("Model not fitted!"),
}
}