resopt 0.3.0

Declarative constrained residual optimization in Rust
Documentation
use crate::core::{
    Bounds, ConstrainedResidualProblem, Error, LinearEqualities, LinearInequalities,
    LinearResidual, Loss, Matrix, TikhonovRegularization, Vector,
};

/// Ergonomic builder for constrained residual problems.
#[derive(Debug, Default, Clone)]
pub struct ConstrainedResidualProblemBuilder {
    matrix: Option<Matrix>,
    target: Option<Vector>,
    residual: Option<LinearResidual>,
    loss: Option<Loss>,
    equalities: Vec<LinearEqualities>,
    inequalities: Vec<LinearInequalities>,
    bounds: Option<Bounds>,
    regularization: Option<TikhonovRegularization>,
}

impl ConstrainedResidualProblemBuilder {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn matrix(mut self, matrix: Matrix) -> Self {
        self.matrix = Some(matrix);
        self
    }

    pub fn target(mut self, target: Vector) -> Self {
        self.target = Some(target);
        self
    }

    pub fn residual(mut self, residual: LinearResidual) -> Self {
        self.residual = Some(residual);
        self
    }

    pub fn loss(mut self, loss: Loss) -> Self {
        self.loss = Some(loss);
        self
    }

    pub fn add_equalities(mut self, equalities: LinearEqualities) -> Self {
        self.equalities.push(equalities);
        self
    }

    pub fn add_inequalities(mut self, inequalities: LinearInequalities) -> Self {
        self.inequalities.push(inequalities);
        self
    }

    pub fn bounds(mut self, bounds: Bounds) -> Self {
        self.bounds = Some(bounds);
        self
    }

    pub fn regularization(mut self, regularization: TikhonovRegularization) -> Self {
        self.regularization = Some(regularization);
        self
    }

    pub fn build(self) -> Result<ConstrainedResidualProblem, Error> {
        let residual = match (self.residual, self.matrix, self.target) {
            (Some(residual), None, None) => residual,
            (None, Some(matrix), Some(target)) => LinearResidual::new(matrix, target)?,
            (Some(_), Some(_), _) | (Some(_), _, Some(_)) => {
                return Err(Error::InvalidParameter {
                    message: "provide either `residual(...)` or (`matrix(...)` and `target(...)`), but not both"
                        .to_string(),
                });
            }
            _ => return Err(Error::MissingField { field: "residual" }),
        };

        let loss = self.loss.ok_or(Error::MissingField { field: "loss" })?;

        let mut problem = ConstrainedResidualProblem::new(residual, loss)?;

        for eq in self.equalities {
            problem = problem.add_equalities(eq)?;
        }

        for ineq in self.inequalities {
            problem = problem.add_inequalities(ineq)?;
        }

        if let Some(bounds) = self.bounds {
            problem = problem.with_bounds(bounds)?;
        }

        if let Some(regularization) = self.regularization {
            problem = problem.with_regularization(regularization)?;
        }

        Ok(problem)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::ProblemClass;

    #[test]
    fn minimal_with_matrix_and_target() {
        let m = Matrix::from_row_major(2, 3, vec![1.0; 6]).unwrap();
        let p = ConstrainedResidualProblemBuilder::new()
            .matrix(m)
            .target(vec![1.0, 2.0])
            .loss(Loss::L2Squared)
            .build()
            .unwrap();
        assert_eq!(p.x_dim(), 3);
        assert_eq!(p.residual_dim(), 2);
        assert_eq!(p.class(), ProblemClass::Unconstrained);
    }

    #[test]
    fn minimal_with_residual() {
        let m = Matrix::from_row_major(2, 2, vec![1.0, 0.0, 0.0, 1.0]).unwrap();
        let residual = LinearResidual::new(m, vec![1.0, 2.0]).unwrap();
        let p = ConstrainedResidualProblemBuilder::new()
            .residual(residual)
            .loss(Loss::L1)
            .build()
            .unwrap();
        assert_eq!(p.loss(), &Loss::L1);
    }

    #[test]
    fn with_equalities() {
        let m = Matrix::from_row_major(2, 2, vec![1.0; 4]).unwrap();
        let eq_m = Matrix::from_row_major(1, 2, vec![1.0, 1.0]).unwrap();
        let eq = LinearEqualities::new(eq_m, vec![1.0]).unwrap();
        let p = ConstrainedResidualProblemBuilder::new()
            .matrix(m)
            .target(vec![0.0, 0.0])
            .loss(Loss::L2Squared)
            .add_equalities(eq)
            .build()
            .unwrap();
        assert_eq!(p.equalities().len(), 1);
        assert_eq!(p.class(), ProblemClass::EqualityConstrained);
    }

    #[test]
    fn with_inequalities() {
        let m = Matrix::from_row_major(2, 2, vec![1.0; 4]).unwrap();
        let ineq_m = Matrix::from_row_major(1, 2, vec![1.0, -1.0]).unwrap();
        let ineq = LinearInequalities::new(ineq_m, vec![0.0]).unwrap();
        let p = ConstrainedResidualProblemBuilder::new()
            .matrix(m)
            .target(vec![0.0, 0.0])
            .loss(Loss::LInf)
            .add_inequalities(ineq)
            .build()
            .unwrap();
        assert_eq!(p.inequalities().len(), 1);
        assert_eq!(p.class(), ProblemClass::InequalityConstrained);
    }

    #[test]
    fn with_bounds() {
        let m = Matrix::from_row_major(2, 2, vec![1.0; 4]).unwrap();
        let p = ConstrainedResidualProblemBuilder::new()
            .matrix(m)
            .target(vec![0.0, 0.0])
            .loss(Loss::L2Squared)
            .bounds(Bounds::nonnegative(2))
            .build()
            .unwrap();
        assert!(p.bounds().is_some());
        assert_eq!(p.class(), ProblemClass::Bounded);
    }

    #[test]
    fn with_regularization() {
        let m = Matrix::from_row_major(2, 2, vec![1.0; 4]).unwrap();
        let p = ConstrainedResidualProblemBuilder::new()
            .matrix(m)
            .target(vec![0.0, 0.0])
            .loss(Loss::L2Squared)
            .regularization(TikhonovRegularization::ridge(2, 1.0).unwrap())
            .build()
            .unwrap();

        assert!(p.regularization().is_some());
        assert_eq!(p.summary().regularization_dim, 2);
    }

    #[test]
    fn mixed_constrained() {
        let m = Matrix::from_row_major(2, 2, vec![1.0; 4]).unwrap();
        let eq_m = Matrix::from_row_major(1, 2, vec![1.0, 1.0]).unwrap();
        let ineq_m = Matrix::from_row_major(1, 2, vec![1.0, 0.0]).unwrap();
        let p = ConstrainedResidualProblemBuilder::new()
            .matrix(m)
            .target(vec![0.0, 0.0])
            .loss(Loss::Huber { delta: 1.0 })
            .add_equalities(LinearEqualities::new(eq_m, vec![1.0]).unwrap())
            .add_inequalities(LinearInequalities::new(ineq_m, vec![5.0]).unwrap())
            .build()
            .unwrap();
        assert_eq!(p.class(), ProblemClass::MixedConstrained);
    }

    #[test]
    fn missing_residual() {
        let result = ConstrainedResidualProblemBuilder::new()
            .loss(Loss::L2Squared)
            .build();
        assert_eq!(
            result.unwrap_err(),
            Error::MissingField { field: "residual" }
        );
    }

    #[test]
    fn missing_loss() {
        let m = Matrix::from_row_major(1, 1, vec![1.0]).unwrap();
        let result = ConstrainedResidualProblemBuilder::new()
            .matrix(m)
            .target(vec![1.0])
            .build();
        assert_eq!(result.unwrap_err(), Error::MissingField { field: "loss" });
    }

    #[test]
    fn both_residual_and_matrix() {
        let m = Matrix::from_row_major(1, 1, vec![1.0]).unwrap();
        let residual = LinearResidual::new(m.clone(), vec![1.0]).unwrap();
        let result = ConstrainedResidualProblemBuilder::new()
            .residual(residual)
            .matrix(m)
            .loss(Loss::L2Squared)
            .build();
        match result.unwrap_err() {
            Error::InvalidParameter { message } => assert!(message.contains("not both")),
            other => panic!("unexpected: {:?}", other),
        }
    }

    #[test]
    fn residual_dimension_mismatch() {
        let m = Matrix::from_row_major(2, 2, vec![1.0; 4]).unwrap();
        assert!(ConstrainedResidualProblemBuilder::new()
            .matrix(m)
            .target(vec![1.0])
            .loss(Loss::L2Squared)
            .build()
            .is_err());
    }

    #[test]
    fn equality_dimension_mismatch() {
        let m = Matrix::from_row_major(2, 2, vec![1.0; 4]).unwrap();
        let eq_m = Matrix::from_row_major(1, 3, vec![1.0; 3]).unwrap();
        assert!(ConstrainedResidualProblemBuilder::new()
            .matrix(m)
            .target(vec![1.0, 2.0])
            .loss(Loss::L2Squared)
            .add_equalities(LinearEqualities::new(eq_m, vec![1.0]).unwrap())
            .build()
            .is_err());
    }

    #[test]
    fn bounds_dimension_mismatch() {
        let m = Matrix::from_row_major(2, 2, vec![1.0; 4]).unwrap();
        assert!(ConstrainedResidualProblemBuilder::new()
            .matrix(m)
            .target(vec![1.0, 2.0])
            .loss(Loss::L2Squared)
            .bounds(Bounds::free(5))
            .build()
            .is_err());
    }

    #[test]
    fn regularization_dimension_mismatch() {
        let m = Matrix::from_row_major(2, 2, vec![1.0; 4]).unwrap();
        assert!(ConstrainedResidualProblemBuilder::new()
            .matrix(m)
            .target(vec![1.0, 2.0])
            .loss(Loss::L2Squared)
            .regularization(TikhonovRegularization::ridge(5, 1.0).unwrap())
            .build()
            .is_err());
    }

    #[test]
    fn invalid_huber_delta() {
        let m = Matrix::from_row_major(1, 1, vec![1.0]).unwrap();
        assert!(ConstrainedResidualProblemBuilder::new()
            .matrix(m)
            .target(vec![1.0])
            .loss(Loss::Huber { delta: -1.0 })
            .build()
            .is_err());
    }
}