use argmin::core::{CostFunction, Error, Executor, Gradient, Hessian, State};
use argmin::solver::linesearch::MoreThuenteLineSearch;
use argmin::solver::neldermead::NelderMead;
use argmin::solver::quasinewton::LBFGS;
use finitediff::vec;
use levenberg_marquardt::{LeastSquaresProblem, LevenbergMarquardt};
use nalgebra::storage::Owned;
use nalgebra::{DMatrix, DVector, Dyn};
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use std::cell::RefCell;
use std::collections::HashMap;
use crate::geometry::diagram::RegionMask;
use crate::geometry::traits::DiagramShape;
use crate::loss::LossType;
use crate::spec::PreprocessedSpec;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Optimizer {
LevenbergMarquardt,
Lbfgs,
NelderMead,
#[default]
CmaEsLm,
}
#[derive(Debug, Clone)]
pub(crate) struct FinalLayoutConfig {
pub max_iterations: usize,
pub loss_type: crate::loss::LossType,
pub optimizer: Optimizer,
pub tolerance: f64,
pub seed: u64,
pub n_restarts: usize,
pub cmaes_fallback_threshold: f64,
}
impl Default for FinalLayoutConfig {
fn default() -> Self {
Self {
max_iterations: 200,
loss_type: crate::loss::LossType::default(),
optimizer: Optimizer::LevenbergMarquardt,
tolerance: 1e-6,
seed: 0xDEAD_BEEF,
n_restarts: 10,
cmaes_fallback_threshold: 1e-3,
}
}
}
pub(crate) fn optimize_layout<S: DiagramShape + Copy + 'static>(
spec: &PreprocessedSpec,
initial_positions: &[f64], initial_radii: &[f64], config: FinalLayoutConfig,
) -> Result<(Vec<f64>, f64), Error> {
let n_sets = spec.n_sets;
let params_per_shape = S::n_params();
let mean_radius = if !initial_radii.is_empty() {
initial_radii.iter().sum::<f64>() / initial_radii.len() as f64
} else {
1.0
};
let mut best: Option<(Vec<f64>, f64)> = None;
let mut last_err: Option<Error> = None;
let n_restarts = config.n_restarts.max(1);
let mut rng = StdRng::seed_from_u64(config.seed ^ 0x52455354_41525453);
for attempt in 0..n_restarts {
let mut positions = initial_positions.to_vec();
if attempt > 0 {
for i in 0..n_sets {
let u1: f64 = rng.random_range(f64::EPSILON..1.0);
let u2: f64 = rng.random_range(0.0..1.0);
let mag = (-2.0 * u1.ln()).sqrt();
let dx = mag * (2.0 * std::f64::consts::PI * u2).cos();
let dy = mag * (2.0 * std::f64::consts::PI * u2).sin();
positions[i * 2] += dx * mean_radius;
positions[i * 2 + 1] += dy * mean_radius;
}
}
let mut initial_params = Vec::with_capacity(n_sets * params_per_shape);
for i in 0..n_sets {
let x = positions[i * 2];
let y = positions[i * 2 + 1];
let r = initial_radii[i];
initial_params.extend(S::params_from_circle(x, y, r));
}
let initial_param = DVector::from_vec(initial_params);
let attempt_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
optimize_from_initial::<S>(spec, &initial_param, &config)
}));
match attempt_result {
Ok(Ok((params, loss))) => {
let params_vec = params.as_slice().to_vec();
match &best {
None => best = Some((params_vec, loss)),
Some((_, best_loss)) if loss < *best_loss => best = Some((params_vec, loss)),
_ => {}
}
}
Ok(Err(e)) => last_err = Some(e),
Err(_) => {
}
}
}
match best {
Some(b) => Ok(b),
None => Err(last_err.unwrap_or_else(|| {
Error::msg("all optimization restarts failed (panicked or errored)")
})),
}
}
pub(crate) fn optimize_from_initial<S: DiagramShape + Copy + 'static>(
spec: &PreprocessedSpec,
initial_param: &DVector<f64>,
config: &FinalLayoutConfig,
) -> Result<(DVector<f64>, f64), Error> {
let params_per_shape = S::n_params();
let (final_params, loss) = match config.optimizer {
Optimizer::NelderMead => {
let initial_simplex = {
let n_params = initial_param.len();
let mut simplex = Vec::with_capacity(n_params + 1);
simplex.push(initial_param.clone());
for i in 0..n_params {
let mut perturbed = initial_param.clone();
let x0_i = initial_param[i];
let param_idx = i % params_per_shape;
let is_rotation = params_per_shape == 5 && param_idx == 4;
let delta = if x0_i.abs() > 1e-10 {
0.05 * x0_i.abs()
} else if is_rotation {
0.2 } else {
0.00025
};
perturbed[i] += delta;
simplex.push(perturbed);
}
simplex
};
let cost_function = DiagramCost::<S> {
spec,
loss_type: config.loss_type,
params_per_shape,
_shape: std::marker::PhantomData,
};
let solver = NelderMead::new(initial_simplex);
let result = Executor::new(cost_function, solver)
.configure(|state| state.max_iters(config.max_iterations as u64))
.run()?;
(
result.state().get_best_param().unwrap().clone(),
result.state().get_cost(),
)
}
Optimizer::Lbfgs => {
let cost_function_lbfgs = DiagramCost::<S> {
spec,
loss_type: config.loss_type,
params_per_shape,
_shape: std::marker::PhantomData,
};
let line_search = MoreThuenteLineSearch::new();
let solver = LBFGS::new(line_search, 10)
.with_tolerance_grad(config.tolerance)?
.with_tolerance_cost(config.tolerance)?;
let result = Executor::new(cost_function_lbfgs, solver)
.configure(|state| {
state
.param(initial_param.clone())
.max_iters(config.max_iterations as u64)
})
.run()?;
(
result.state().get_best_param().unwrap().clone(),
result.state().get_cost(),
)
}
Optimizer::LevenbergMarquardt => {
run_lm_or_lbfgs::<S>(spec, params_per_shape, initial_param, config)?
}
Optimizer::CmaEsLm => {
let lm_only = run_lm_or_lbfgs::<S>(spec, params_per_shape, initial_param, config)?;
if lm_only.1 <= config.cmaes_fallback_threshold {
lm_only
} else {
let (lower, upper, initial_std) =
cmaes_bounds_for(initial_param.as_slice(), params_per_shape);
let cma_cost = DiagramCost::<S> {
spec,
loss_type: config.loss_type,
params_per_shape,
_shape: std::marker::PhantomData,
};
let cma_config = crate::fitter::cmaes::CmaEsConfig {
lower,
upper,
initial_mean: initial_param.as_slice().to_vec(),
initial_std,
max_iters: 100,
fn_tol: config.tolerance.max(1e-12),
seed: config.seed.wrapping_mul(0x9E37_79B9_7F4A_7C15),
lambda: None,
max_evals: None,
};
let cma_result = crate::fitter::cmaes::minimize(&cma_config, |x| {
let p = DVector::from_vec(x.to_vec());
match cma_cost.cost(&p) {
Ok(c) if c.is_finite() => c,
_ => f64::INFINITY,
}
});
let polish_init = DVector::from_vec(cma_result.best_x);
let cma_lm = run_lm_or_lbfgs::<S>(spec, params_per_shape, &polish_init, config)?;
if cma_lm.1 < lm_only.1 {
cma_lm
} else {
lm_only
}
}
}
};
Ok((final_params, loss))
}
fn run_lm_or_lbfgs<S: DiagramShape + Copy + 'static>(
spec: &PreprocessedSpec,
params_per_shape: usize,
initial_param: &DVector<f64>,
config: &FinalLayoutConfig,
) -> Result<(DVector<f64>, f64), Error> {
match LmDiagramProblem::<S>::new(spec, params_per_shape, config.loss_type, initial_param) {
Ok(problem) => {
let solver = LevenbergMarquardt::new()
.with_ftol(config.tolerance)
.with_xtol(config.tolerance)
.with_gtol(config.tolerance)
.with_patience(config.max_iterations.max(1));
let (problem_after, report) = solver.minimize(problem);
let final_loss = report.objective_function * 2.0;
Ok((problem_after.params.clone(), final_loss))
}
Err(_incompatible_loss) => {
let cost_function_lbfgs = DiagramCost::<S> {
spec,
loss_type: config.loss_type,
params_per_shape,
_shape: std::marker::PhantomData,
};
let line_search = MoreThuenteLineSearch::new();
let solver = LBFGS::new(line_search, 10)
.with_tolerance_grad(config.tolerance)?
.with_tolerance_cost(config.tolerance)?;
let result = Executor::new(cost_function_lbfgs, solver)
.configure(|state| {
state
.param(initial_param.clone())
.max_iters(config.max_iterations as u64)
})
.run()?;
Ok((
result.state().get_best_param().unwrap().clone(),
result.state().get_cost(),
))
}
}
}
fn cmaes_bounds_for(
initial_param: &[f64],
params_per_shape: usize,
) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
let n = initial_param.len();
let n_shapes = n / params_per_shape.max(1);
let mut xs = Vec::with_capacity(n_shapes);
let mut ys = Vec::with_capacity(n_shapes);
let mut radii = Vec::with_capacity(n_shapes);
for k in 0..n_shapes {
let base = k * params_per_shape;
xs.push(initial_param[base]);
ys.push(initial_param[base + 1]);
match params_per_shape {
3 => radii.push(initial_param[base + 2]),
5 => {
radii.push(initial_param[base + 2].exp());
radii.push(initial_param[base + 3].exp());
}
_ => {}
}
}
let mean_x: f64 = xs.iter().sum::<f64>() / xs.len().max(1) as f64;
let mean_y: f64 = ys.iter().sum::<f64>() / ys.len().max(1) as f64;
let max_radius: f64 = radii.iter().cloned().fold(0.0_f64, f64::max).max(1e-6);
let span_x: f64 = xs
.iter()
.map(|x| (x - mean_x).abs())
.fold(0.0_f64, f64::max);
let span_y: f64 = ys
.iter()
.map(|y| (y - mean_y).abs())
.fold(0.0_f64, f64::max);
let pos_scale: f64 = span_x.max(span_y).max(max_radius);
let pos_margin: f64 = 4.0 * pos_scale;
let mut lower = Vec::with_capacity(n);
let mut upper = Vec::with_capacity(n);
let mut std_dev = Vec::with_capacity(n);
for k in 0..n_shapes {
lower.push(mean_x - pos_margin);
upper.push(mean_x + pos_margin);
std_dev.push((pos_scale * 0.5).max(1e-6));
lower.push(mean_y - pos_margin);
upper.push(mean_y + pos_margin);
std_dev.push((pos_scale * 0.5).max(1e-6));
match params_per_shape {
3 => {
lower.push(1e-6 * max_radius);
upper.push(5.0 * max_radius);
std_dev.push((max_radius * 0.5).max(1e-6));
}
5 => {
let u_lower = (1e-6 * max_radius).ln();
let u_upper = (5.0 * max_radius).ln();
let log_std = (u_upper - u_lower) * 0.1; lower.push(u_lower);
upper.push(u_upper);
std_dev.push(log_std);
lower.push(u_lower);
upper.push(u_upper);
std_dev.push(log_std);
lower.push(f64::NEG_INFINITY);
upper.push(f64::INFINITY);
std_dev.push(std::f64::consts::FRAC_PI_4);
}
other => {
let extra = other.saturating_sub(2);
let _ = (k, extra); for j in 2..other {
let v = initial_param[k * other + j];
lower.push(f64::NEG_INFINITY);
upper.push(f64::INFINITY);
std_dev.push(v.abs().max(0.5));
}
}
}
}
(lower, upper, std_dev)
}
struct DiagramCost<'a, S: DiagramShape + Copy + 'static> {
spec: &'a PreprocessedSpec,
loss_type: crate::loss::LossType,
params_per_shape: usize,
_shape: std::marker::PhantomData<S>,
}
impl<'a, S: DiagramShape + Copy + 'static> DiagramCost<'a, S> {
fn params_to_shapes(&self, params: &DVector<f64>) -> Vec<S> {
let n_sets = self.spec.n_sets;
(0..n_sets)
.map(|i| {
let start = i * self.params_per_shape;
let end = start + self.params_per_shape;
S::from_params(¶ms.as_slice()[start..end])
})
.collect()
}
}
impl<'a, S: DiagramShape + Copy + 'static> CostFunction for DiagramCost<'a, S> {
type Param = DVector<f64>;
type Output = f64;
fn cost(&self, param: &Self::Param) -> Result<Self::Output, Error> {
let shapes = self.params_to_shapes(param);
let exclusive_areas = S::compute_exclusive_regions(&shapes);
let error = self
.loss_type
.compute(&exclusive_areas, &self.spec.exclusive_areas);
Ok(error)
}
}
impl<'a, S: DiagramShape + Copy + 'static> Gradient for DiagramCost<'a, S> {
type Param = DVector<f64>;
type Gradient = DVector<f64>;
fn gradient(&self, param: &Self::Param) -> Result<Self::Gradient, Error> {
let shapes = self.params_to_shapes(param);
if let Some((fitted, fitted_grads)) = S::compute_exclusive_regions_with_gradient(&shapes) {
if let Some((_loss, loss_grad)) = self
.loss_type
.compute_with_gradient(&fitted, &self.spec.exclusive_areas)
{
let n_params = param.len();
let mut grad = vec![0.0; n_params];
for (mask, &dl_df) in loss_grad.iter() {
if let Some(df_dtheta) = fitted_grads.get(mask) {
for (k, item) in grad.iter_mut().enumerate().take(n_params) {
*item += dl_df * df_dtheta[k];
}
}
}
return Ok(DVector::from_vec(grad));
}
}
let param_vec = param.as_slice().to_vec();
let f = |x: &Vec<f64>| {
let p = DVector::from_vec(x.to_vec());
Ok(self.cost(&p).unwrap_or(f64::INFINITY))
};
let g_central = vec::central_diff(&f);
let grad_vec = g_central(¶m_vec)?;
Ok(DVector::from_vec(grad_vec))
}
}
impl<'a, S: DiagramShape + Copy + 'static> Hessian for DiagramCost<'a, S> {
type Param = DVector<f64>;
type Hessian = Vec<Vec<f64>>;
fn hessian(&self, param: &Self::Param) -> Result<Self::Hessian, Error> {
let param_vec = param.as_slice().to_vec();
let grad_fn = |x: &Vec<f64>| -> Result<Vec<f64>, argmin::core::Error> {
let p = DVector::from_vec(x.to_vec());
let grad = self.gradient(&p)?;
Ok(grad.as_slice().to_vec())
};
let h_central = finitediff::vec::central_hessian(&grad_fn);
let hessian = h_central(¶m_vec)?;
Ok(hessian)
}
}
struct LmDiagramProblem<'a, S: DiagramShape + Copy + 'static> {
spec: &'a PreprocessedSpec,
params_per_shape: usize,
masks: Vec<RegionMask>,
norm_factor: f64,
params: DVector<f64>,
cache: RefCell<Option<DiagramCache>>,
_shape: std::marker::PhantomData<S>,
}
struct DiagramCache {
fitted: HashMap<RegionMask, f64>,
fitted_grads: HashMap<RegionMask, Vec<f64>>,
}
impl<'a, S: DiagramShape + Copy + 'static> LmDiagramProblem<'a, S> {
fn new(
spec: &'a PreprocessedSpec,
params_per_shape: usize,
loss_type: LossType,
initial_param: &DVector<f64>,
) -> Result<Self, Error> {
let norm_factor = match loss_type {
LossType::SumSquared => 1.0,
LossType::NormalizedSumSquared => {
let sum_t2: f64 = spec.exclusive_areas.values().map(|&v| v * v).sum();
if sum_t2 < 1e-20 {
return Err(Error::msg(
"Levenberg-Marquardt: target area norm is zero, cannot normalise",
));
}
1.0 / sum_t2.sqrt()
}
other => {
return Err(Error::msg(format!(
"Levenberg-Marquardt only supports SumSquared / NormalizedSumSquared losses, got {other:?}"
)));
}
};
let n_sets = spec.n_sets;
let masks: Vec<RegionMask> = (1..(1usize << n_sets)).collect();
Ok(Self {
spec,
params_per_shape,
masks,
norm_factor,
params: initial_param.clone(),
cache: RefCell::new(None),
_shape: std::marker::PhantomData,
})
}
fn ensure_cache(&self) {
let mut slot = self.cache.borrow_mut();
if slot.is_some() {
return;
}
let n_sets = self.spec.n_sets;
let shapes: Vec<S> = (0..n_sets)
.map(|i| {
let start = i * self.params_per_shape;
let end = start + self.params_per_shape;
S::from_params(&self.params.as_slice()[start..end])
})
.collect();
let (fitted, fitted_grads) = match S::compute_exclusive_regions_with_gradient(&shapes) {
Some(pair) => pair,
None => {
let fitted = S::compute_exclusive_regions(&shapes);
(fitted, HashMap::new())
}
};
*slot = Some(DiagramCache {
fitted,
fitted_grads,
});
}
}
impl<'a, S: DiagramShape + Copy + 'static> LeastSquaresProblem<f64, Dyn, Dyn>
for LmDiagramProblem<'a, S>
{
type ResidualStorage = Owned<f64, Dyn>;
type JacobianStorage = Owned<f64, Dyn, Dyn>;
type ParameterStorage = Owned<f64, Dyn>;
fn set_params(&mut self, x: &DVector<f64>) {
self.params.copy_from(x);
self.cache.borrow_mut().take();
}
fn params(&self) -> DVector<f64> {
self.params.clone()
}
fn residuals(&self) -> Option<DVector<f64>> {
self.ensure_cache();
let cache = self.cache.borrow();
let cache = cache.as_ref().expect("cache populated by ensure_cache");
let mut residuals = DVector::zeros(self.masks.len());
for (i, &mask) in self.masks.iter().enumerate() {
let f = cache.fitted.get(&mask).copied().unwrap_or(0.0);
let t = self.spec.exclusive_areas.get(&mask).copied().unwrap_or(0.0);
residuals[i] = self.norm_factor * (f - t);
}
Some(residuals)
}
fn jacobian(&self) -> Option<DMatrix<f64>> {
self.ensure_cache();
let cache = self.cache.borrow();
let cache = cache.as_ref().expect("cache populated by ensure_cache");
let n_params = self.params.len();
let mut jacobian = DMatrix::zeros(self.masks.len(), n_params);
for (i, &mask) in self.masks.iter().enumerate() {
if let Some(grad) = cache.fitted_grads.get(&mask) {
for (j, &g) in grad.iter().enumerate().take(n_params) {
jacobian[(i, j)] = self.norm_factor * g;
}
}
}
Some(jacobian)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geometry::diagram;
use crate::geometry::primitives::Point;
use crate::geometry::shapes::Circle;
use crate::spec::{DiagramSpec, DiagramSpecBuilder};
mod helpers {
use super::*;
use crate::Combination;
use std::collections::HashMap;
pub fn generate_random_diagram(n_sets: usize, seed: u64) -> (DiagramSpec, Vec<Circle>) {
let (circles, set_names) = random_circle_layout(n_sets, seed);
let exclusive_areas =
diagram::compute_exclusive_areas_from_layout(&circles, &set_names);
let spec = create_spec_from_exclusive(exclusive_areas);
(spec, circles)
}
pub fn random_circle_layout(n_sets: usize, seed: u64) -> (Vec<Circle>, Vec<String>) {
use rand::Rng;
use rand::SeedableRng;
let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
let set_names: Vec<String> = (0..n_sets).map(|i| format!("Set{}", i)).collect();
let mut circles = Vec::new();
for _ in 0..n_sets {
let x = rng.random_range(-5.0..5.0);
let y = rng.random_range(-5.0..5.0);
let r = rng.random_range(0.5..2.0);
circles.push(Circle::new(Point::new(x, y), r));
}
(circles, set_names)
}
pub fn create_spec_from_exclusive(
exclusive_areas: HashMap<Combination, f64>,
) -> DiagramSpec {
let mut builder = DiagramSpecBuilder::new();
let mut singles: Vec<_> = exclusive_areas
.iter()
.filter(|(combo, _)| combo.sets().len() == 1)
.collect();
singles.sort_by_key(|(combo, _)| combo.sets()[0].clone());
for (combo, &area) in &singles {
builder = builder.set(combo.sets()[0].as_str(), area);
}
for (combo, &area) in &exclusive_areas {
if combo.sets().len() > 1 {
let sets: Vec<&str> = combo.sets().iter().map(|s| s.as_str()).collect();
builder = builder.intersection(&sets, area);
}
}
builder.build().unwrap()
}
}
#[test]
fn test_optimize_layout_simple() {
let spec = DiagramSpecBuilder::new()
.set("A", 3.0)
.set("B", 4.0)
.intersection(&["A", "B"], 1.0)
.build()
.unwrap();
let preprocessed = spec.preprocess().unwrap();
let positions = vec![0.0, 0.0, 5.0, 0.0];
let radii = vec![
(3.0 / std::f64::consts::PI).sqrt(),
(4.0 / std::f64::consts::PI).sqrt(),
];
let config = FinalLayoutConfig {
optimizer: Optimizer::NelderMead,
loss_type: crate::loss::LossType::sse(),
max_iterations: 50,
tolerance: 1e-4,
seed: 0,
n_restarts: 1,
cmaes_fallback_threshold: 1e-3,
};
let result = optimize_layout::<Circle>(&preprocessed, &positions, &radii, config);
assert!(result.is_ok());
let (final_params, loss) = result.unwrap();
assert_eq!(final_params.len(), 2 * Circle::n_params()); assert!(loss >= 0.0);
}
#[test]
fn test_cost_function_computes() {
let spec = DiagramSpecBuilder::new()
.set("A", 5.0)
.set("B", 5.0)
.intersection(&["A", "B"], 2.0)
.build()
.unwrap();
let preprocessed = spec.preprocess().unwrap();
let cost_fn = DiagramCost::<Circle> {
spec: &preprocessed,
loss_type: crate::loss::LossType::sse(),
params_per_shape: Circle::n_params(),
_shape: std::marker::PhantomData,
};
let positions = vec![0.0, 0.0, 2.0, 0.0];
let radii = vec![
(5.0 / std::f64::consts::PI).sqrt(),
(5.0 / std::f64::consts::PI).sqrt(),
];
let mut params = positions.clone();
params.extend_from_slice(&radii);
let param_vec = DVector::from_vec(params);
let result = cost_fn.cost(¶m_vec);
assert!(result.is_ok(), "Cost function should compute successfully");
let error = result.unwrap();
assert!(error >= 0.0, "Error should be non-negative");
}
#[test]
fn test_reproduce_simple_two_circle_layout() {
use helpers::*;
let c1 = Circle::new(Point::new(0.0, 0.0), 1.0);
let c2 = Circle::new(Point::new(1.5, 0.0), 1.0);
let circles = vec![c1, c2];
let set_names = vec!["A".to_string(), "B".to_string()];
let exclusive = diagram::compute_exclusive_areas_from_layout(&circles, &set_names);
let spec = create_spec_from_exclusive(exclusive);
let preprocessed = spec.preprocess().unwrap();
let positions = vec![0.0, 0.0, 1.5, 0.0];
let radii = vec![1.0, 1.0];
let config = FinalLayoutConfig {
optimizer: Optimizer::NelderMead,
loss_type: crate::loss::LossType::sse(),
max_iterations: 100,
tolerance: 1e-6,
seed: 0,
n_restarts: 1,
cmaes_fallback_threshold: 1e-3,
};
let result = optimize_layout::<Circle>(&preprocessed, &positions, &radii, config);
assert!(result.is_ok());
let (_, loss) = result.unwrap();
assert!(
loss < 1e-2,
"Should reproduce layout with low error, got: {}",
loss
);
}
#[test]
fn test_reproduce_random_three_circle_layout() {
use helpers::*;
let (circles, set_names) = random_circle_layout(3, 42);
let exclusive_areas = diagram::compute_exclusive_areas_from_layout(&circles, &set_names);
let spec = create_spec_from_exclusive(exclusive_areas);
let preprocessed = spec.preprocess().unwrap();
let mut positions = Vec::new();
let mut radii = Vec::new();
for c in &circles {
positions.push(c.center().x());
positions.push(c.center().y());
radii.push(c.radius());
}
let config = FinalLayoutConfig {
optimizer: Optimizer::NelderMead,
loss_type: crate::loss::LossType::sse(),
max_iterations: 200,
tolerance: 1e-6,
seed: 0,
n_restarts: 1,
cmaes_fallback_threshold: 1e-3,
};
let result = optimize_layout::<Circle>(&preprocessed, &positions, &radii, config);
assert!(result.is_ok());
let (_final_pos, loss) = result.unwrap();
assert!(
loss < 5.0,
"Should reproduce random layout reasonably, got: {}",
loss
);
}
#[test]
fn test_reproduce_multiple_random_diagrams() {
use helpers::*;
let test_configs = [
(2, 100), (2, 200), (3, 300), (3, 400), (4, 501), ];
let mut results = Vec::new();
for (i, &(n_sets, seed)) in test_configs.iter().enumerate() {
let (spec, original_circles) = generate_random_diagram(n_sets, seed);
let preprocessed = spec.preprocess().unwrap();
let mut positions = Vec::new();
let mut radii = Vec::new();
for c in &original_circles {
positions.push(c.center().x());
positions.push(c.center().y());
radii.push(c.radius());
}
let config = FinalLayoutConfig {
optimizer: Optimizer::NelderMead,
loss_type: crate::loss::LossType::sse(),
max_iterations: 200,
tolerance: 1e-6,
seed: 0,
n_restarts: 1,
cmaes_fallback_threshold: 1e-3,
};
let result = optimize_layout::<Circle>(&preprocessed, &positions, &radii, config);
assert!(result.is_ok(), "Optimization failed for config {}", i);
let (_, loss) = result.unwrap();
results.push((n_sets, seed, loss));
}
for &(n_sets, seed, loss) in &results {
let tol: f64 = match n_sets {
2 => 2.0, 3 => 5.0, 4 => 10.0, _ => 20.0, };
assert!(
loss < tol,
"configuration n_sets={}, seed={} produced loss={:.6}, \
exceeds tolerance {:.1} — optimizer may have regressed, \
or the seed lands in a bad basin on this platform",
n_sets,
seed,
loss,
tol
);
}
}
fn assert_analytic_matches_fd<F>(
spec: &PreprocessedSpec,
params: &[f64],
loss_type: crate::loss::LossType,
h: f64,
tol: f64,
label: F,
) where
F: FnOnce() -> String,
{
let cost = DiagramCost::<Circle> {
spec,
loss_type,
params_per_shape: Circle::n_params(),
_shape: std::marker::PhantomData,
};
let p = DVector::from_vec(params.to_vec());
let analytic = cost.gradient(&p).expect("analytic gradient");
let n = params.len();
let mut fd = vec![0.0; n];
for i in 0..n {
let mut plus = params.to_vec();
let mut minus = params.to_vec();
plus[i] += h;
minus[i] -= h;
let f_plus = cost.cost(&DVector::from_vec(plus)).expect("cost +");
let f_minus = cost.cost(&DVector::from_vec(minus)).expect("cost -");
fd[i] = (f_plus - f_minus) / (2.0 * h);
}
let analytic_slice: Vec<f64> = analytic.as_slice().to_vec();
let diff_norm: f64 = analytic_slice
.iter()
.zip(fd.iter())
.map(|(a, b)| (a - b).powi(2))
.sum::<f64>()
.sqrt();
let fd_norm: f64 = fd.iter().map(|b| b * b).sum::<f64>().sqrt();
let rel = if fd_norm > 1e-12 {
diff_norm / fd_norm
} else {
diff_norm
};
assert!(
rel < tol,
"{}: analytic vs FD gradient mismatch (rel={:.3e}, |fd|={:.3e})\n analytic={:?}\n fd ={:?}",
label(),
rel,
fd_norm,
analytic_slice,
fd
);
}
fn spec_from_circles(circles: &[Circle]) -> PreprocessedSpec {
use helpers::create_spec_from_exclusive;
let names: Vec<String> = (0..circles.len()).map(|i| format!("S{}", i)).collect();
let exclusive = diagram::compute_exclusive_areas_from_layout(circles, &names);
create_spec_from_exclusive(exclusive).preprocess().unwrap()
}
fn flat_params(circles: &[Circle]) -> Vec<f64> {
let mut v = Vec::with_capacity(3 * circles.len());
for c in circles {
v.push(c.center().x());
v.push(c.center().y());
v.push(c.radius());
}
v
}
#[test]
fn analytic_gradient_two_circle_overlap() {
let circles = vec![
Circle::new(Point::new(0.0, 0.0), 1.0),
Circle::new(Point::new(1.0, 0.0), 1.0),
];
let spec = spec_from_circles(&circles);
let params = vec![0.0, 0.0, 1.0, 1.2, 0.0, 0.95];
for &loss in &[
crate::loss::LossType::SumSquared,
crate::loss::LossType::NormalizedSumSquared,
] {
assert_analytic_matches_fd(&spec, ¶ms, loss, 1e-6, 1e-4, || {
format!("two_circle_overlap loss={:?}", loss)
});
}
}
#[test]
fn analytic_gradient_three_circle_venn() {
let circles = vec![
Circle::new(Point::new(0.0, 0.0), 1.0),
Circle::new(Point::new(1.0, 0.0), 1.0),
Circle::new(Point::new(0.5, 0.866), 1.0),
];
let spec = spec_from_circles(&circles);
let mut params = flat_params(&circles);
params[0] += 0.07;
params[4] -= 0.05;
params[8] += 0.03;
for &loss in &[
crate::loss::LossType::SumSquared,
crate::loss::LossType::NormalizedSumSquared,
] {
assert_analytic_matches_fd(&spec, ¶ms, loss, 1e-6, 1e-3, || {
format!("three_circle_venn loss={:?}", loss)
});
}
}
#[test]
fn analytic_gradient_disjoint() {
let circles = vec![
Circle::new(Point::new(0.0, 0.0), 1.0),
Circle::new(Point::new(5.0, 0.0), 1.2),
Circle::new(Point::new(2.5, 5.0), 0.8),
];
let spec = spec_from_circles(&circles);
let params = flat_params(&circles);
let mut perturbed = params.clone();
perturbed[2] += 0.1;
perturbed[5] -= 0.05;
perturbed[8] += 0.07;
assert_analytic_matches_fd(
&spec,
&perturbed,
crate::loss::LossType::NormalizedSumSquared,
1e-6,
1e-4,
|| "disjoint".to_string(),
);
}
#[test]
fn analytic_gradient_nested() {
let circles = vec![
Circle::new(Point::new(0.0, 0.0), 3.0),
Circle::new(Point::new(0.5, 0.2), 0.8),
];
let spec = spec_from_circles(&circles);
let mut params = flat_params(&circles);
params[3] += 0.1;
params[5] += 0.05;
assert_analytic_matches_fd(
&spec,
¶ms,
crate::loss::LossType::NormalizedSumSquared,
1e-6,
1e-4,
|| "nested".to_string(),
);
}
#[test]
#[ignore]
fn benchmark_gradient_call_only_ellipse() {
use crate::geometry::shapes::Ellipse;
use crate::loss::LossType;
use rand::{Rng, SeedableRng};
use std::time::Instant;
let cases: &[(usize, u64, usize)] = &[(3, 13, 500), (5, 100, 200), (8, 200, 100)];
for &(n, seed, iters) in cases {
let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
let ellipses: Vec<Ellipse> = (0..n)
.map(|_| {
Ellipse::new(
Point::new(rng.random_range(-2.5..2.5), rng.random_range(-2.5..2.5)),
rng.random_range(0.7..1.6),
rng.random_range(0.5..1.0),
rng.random_range(-0.5..0.5),
)
})
.collect();
let spec = spec_from_ellipses(&ellipses);
let mut params = flat_params_ellipse(&ellipses);
for v in &mut params {
*v += rng.random_range(-0.04..0.04);
}
let p = DVector::from_vec(params.clone());
let cost = DiagramCost::<Ellipse> {
spec: &spec,
loss_type: LossType::NormalizedSumSquared,
params_per_shape: Ellipse::n_params(),
_shape: std::marker::PhantomData,
};
let t0 = Instant::now();
for _ in 0..iters {
let _ = cost.gradient(&p).unwrap();
}
let dt_an = t0.elapsed();
let t0 = Instant::now();
for _ in 0..iters {
let pv = p.as_slice().to_vec();
let f = |x: &Vec<f64>| {
let pp = DVector::from_vec(x.to_vec());
Ok(cost.cost(&pp).unwrap_or(f64::INFINITY))
};
let g = vec::central_diff(&f);
let _ = g(&pv).unwrap();
}
let dt_fd = t0.elapsed();
eprintln!(
"ellipse n={} ({} grads): analytic={:>7.2}ms ({:>5.1}us/call) | FD={:>7.2}ms ({:>5.1}us/call) | speedup={:.1}x",
n,
iters,
dt_an.as_secs_f64() * 1000.0,
dt_an.as_secs_f64() * 1e6 / iters as f64,
dt_fd.as_secs_f64() * 1000.0,
dt_fd.as_secs_f64() * 1e6 / iters as f64,
dt_fd.as_secs_f64() / dt_an.as_secs_f64(),
);
}
}
#[test]
#[ignore]
fn benchmark_gradient_call_only() {
use crate::loss::LossType;
use rand::{Rng, SeedableRng};
use std::time::Instant;
let cases: &[(usize, u64, usize)] = &[(3, 11, 1000), (5, 33, 500), (8, 55, 200)];
for &(n, seed, iters) in cases {
let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
let circles: Vec<Circle> = (0..n)
.map(|_| {
Circle::new(
Point::new(rng.random_range(-3.0..3.0), rng.random_range(-3.0..3.0)),
rng.random_range(0.6..1.8),
)
})
.collect();
let spec = spec_from_circles(&circles);
let mut params = flat_params(&circles);
for v in &mut params {
*v += rng.random_range(-0.05..0.05);
}
let p = DVector::from_vec(params.clone());
let cost = DiagramCost::<Circle> {
spec: &spec,
loss_type: LossType::NormalizedSumSquared,
params_per_shape: Circle::n_params(),
_shape: std::marker::PhantomData,
};
let t0 = Instant::now();
for _ in 0..iters {
let _ = cost.gradient(&p).unwrap();
}
let dt_an = t0.elapsed();
let cost_fd = || -> f64 {
let dvec = p.clone();
cost.cost(&dvec).unwrap()
};
let _warm = cost_fd();
let t0 = Instant::now();
for _ in 0..iters {
let pv = p.as_slice().to_vec();
let f = |x: &Vec<f64>| {
let pp = DVector::from_vec(x.to_vec());
Ok(cost.cost(&pp).unwrap_or(f64::INFINITY))
};
let g = vec::central_diff(&f);
let _ = g(&pv).unwrap();
}
let dt_fd = t0.elapsed();
eprintln!(
"n={} ({} grads): analytic={:>7.2}ms ({:>5.1}us/call) | FD={:>7.2}ms ({:>5.1}us/call) | speedup={:.1}x",
n,
iters,
dt_an.as_secs_f64() * 1000.0,
dt_an.as_secs_f64() * 1e6 / iters as f64,
dt_fd.as_secs_f64() * 1000.0,
dt_fd.as_secs_f64() * 1e6 / iters as f64,
dt_fd.as_secs_f64() / dt_an.as_secs_f64(),
);
}
}
fn spec_from_ellipses(ellipses: &[crate::geometry::shapes::Ellipse]) -> PreprocessedSpec {
use crate::geometry::traits::Area;
use crate::Combination;
use std::collections::HashMap;
let exclusive =
crate::geometry::shapes::ellipse::Ellipse::compute_exclusive_regions(ellipses);
let names: Vec<String> = (0..ellipses.len()).map(|i| format!("S{}", i)).collect();
let mut combos: HashMap<Combination, f64> = HashMap::new();
for (mask, area) in exclusive {
if area > 0.0 {
let sets: Vec<&str> = (0..ellipses.len())
.filter(|&i| (mask & (1 << i)) != 0)
.map(|i| names[i].as_str())
.collect();
if !sets.is_empty() {
combos.insert(Combination::new(&sets), area);
}
}
}
let single_areas: HashMap<usize, f64> = (0..ellipses.len())
.map(|i| (i, ellipses[i].area()))
.collect();
let mut builder = crate::spec::DiagramSpecBuilder::new();
for i in 0..ellipses.len() {
let area = combos
.get(&Combination::new(&[names[i].as_str()]))
.copied()
.unwrap_or(single_areas[&i]);
builder = builder.set(names[i].as_str(), area);
}
for (combo, area) in &combos {
if combo.sets().len() > 1 {
let sets: Vec<&str> = combo.sets().iter().map(|s| s.as_str()).collect();
builder = builder.intersection(&sets, *area);
}
}
builder.build().unwrap().preprocess().unwrap()
}
fn flat_params_ellipse(ellipses: &[crate::geometry::shapes::Ellipse]) -> Vec<f64> {
let mut v = Vec::with_capacity(5 * ellipses.len());
for e in ellipses {
v.push(e.center().x());
v.push(e.center().y());
v.push(e.semi_major());
v.push(e.semi_minor());
v.push(e.rotation());
}
v
}
fn assert_analytic_matches_fd_shape<S, F>(
spec: &PreprocessedSpec,
params: &[f64],
loss_type: crate::loss::LossType,
h: f64,
tol: f64,
label: F,
) where
S: DiagramShape + Copy + 'static,
F: FnOnce() -> String,
{
let cost = DiagramCost::<S> {
spec,
loss_type,
params_per_shape: S::n_params(),
_shape: std::marker::PhantomData,
};
let p = DVector::from_vec(params.to_vec());
let analytic = cost.gradient(&p).expect("analytic gradient");
let n = params.len();
let mut fd = vec![0.0; n];
for i in 0..n {
let mut plus = params.to_vec();
let mut minus = params.to_vec();
plus[i] += h;
minus[i] -= h;
let f_plus = cost.cost(&DVector::from_vec(plus)).expect("cost +");
let f_minus = cost.cost(&DVector::from_vec(minus)).expect("cost -");
fd[i] = (f_plus - f_minus) / (2.0 * h);
}
let analytic_slice: Vec<f64> = analytic.as_slice().to_vec();
let diff_norm: f64 = analytic_slice
.iter()
.zip(fd.iter())
.map(|(a, b)| (a - b).powi(2))
.sum::<f64>()
.sqrt();
let fd_norm: f64 = fd.iter().map(|b| b * b).sum::<f64>().sqrt();
let rel = if fd_norm > 1e-12 {
diff_norm / fd_norm
} else {
diff_norm
};
assert!(
rel < tol,
"{}: analytic vs FD gradient mismatch (rel={:.3e}, |fd|={:.3e})\n analytic={:?}\n fd ={:?}",
label(),
rel,
fd_norm,
analytic_slice,
fd
);
}
#[test]
fn analytic_gradient_ellipse_two_overlap() {
use crate::geometry::shapes::Ellipse;
let ellipses = vec![
Ellipse::new(Point::new(0.0, 0.0), 1.0, 0.6, 0.0),
Ellipse::new(Point::new(1.0, 0.2), 1.2, 0.5, 0.4),
];
let spec = spec_from_ellipses(&ellipses);
let mut params = flat_params_ellipse(&ellipses);
params[0] += 0.05;
params[4] += 0.07;
for &loss in &[
crate::loss::LossType::SumSquared,
crate::loss::LossType::NormalizedSumSquared,
] {
assert_analytic_matches_fd_shape::<Ellipse, _>(
&spec,
¶ms,
loss,
1e-6,
5e-4,
|| format!("ellipse two_overlap loss={:?}", loss),
);
}
}
#[test]
fn analytic_gradient_ellipse_three_venn() {
use crate::geometry::shapes::Ellipse;
let ellipses = vec![
Ellipse::new(Point::new(0.0, 0.0), 1.2, 0.7, 0.0),
Ellipse::new(Point::new(1.0, 0.0), 1.1, 0.65, 0.3),
Ellipse::new(Point::new(0.5, 0.866), 1.0, 0.8, -0.2),
];
let spec = spec_from_ellipses(&ellipses);
let mut params = flat_params_ellipse(&ellipses);
params[0] += 0.07;
params[4] -= 0.05;
params[8] += 0.03;
params[14] += 0.04;
assert_analytic_matches_fd_shape::<Ellipse, _>(
&spec,
¶ms,
crate::loss::LossType::NormalizedSumSquared,
1e-6,
5e-3,
|| "ellipse three_venn".to_string(),
);
}
#[test]
fn analytic_gradient_ellipse_disjoint() {
use crate::geometry::shapes::Ellipse;
let ellipses = vec![
Ellipse::new(Point::new(0.0, 0.0), 1.0, 0.6, 0.0),
Ellipse::new(Point::new(5.0, 0.0), 1.2, 0.5, 0.5),
Ellipse::new(Point::new(2.5, 5.0), 0.8, 0.4, -0.3),
];
let spec = spec_from_ellipses(&ellipses);
let mut params = flat_params_ellipse(&ellipses);
params[2] += 0.1;
params[3] -= 0.05;
params[4] += 0.07;
assert_analytic_matches_fd_shape::<Ellipse, _>(
&spec,
¶ms,
crate::loss::LossType::NormalizedSumSquared,
1e-6,
5e-4,
|| "ellipse disjoint".to_string(),
);
}
#[test]
fn analytic_gradient_ellipse_nested() {
use crate::geometry::shapes::Ellipse;
let ellipses = vec![
Ellipse::new(Point::new(0.0, 0.0), 3.0, 2.5, 0.1),
Ellipse::new(Point::new(0.5, 0.2), 0.8, 0.5, 0.3),
];
let spec = spec_from_ellipses(&ellipses);
let mut params = flat_params_ellipse(&ellipses);
params[5] += 0.1;
params[7] += 0.05;
params[9] += 0.05;
assert_analytic_matches_fd_shape::<Ellipse, _>(
&spec,
¶ms,
crate::loss::LossType::NormalizedSumSquared,
1e-6,
5e-4,
|| "ellipse nested".to_string(),
);
}
#[test]
fn analytic_gradient_ellipse_random_layouts() {
use crate::geometry::shapes::Ellipse;
use rand::Rng;
use rand::SeedableRng;
let configs: &[(usize, u64)] = &[(2, 7), (3, 13), (3, 21), (5, 100)];
for &(n, seed) in configs {
let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
let ellipses: Vec<Ellipse> = (0..n)
.map(|_| {
Ellipse::new(
Point::new(rng.random_range(-2.5..2.5), rng.random_range(-2.5..2.5)),
rng.random_range(0.7..1.6),
rng.random_range(0.5..1.0),
rng.random_range(-0.5..0.5),
)
})
.collect();
let spec = spec_from_ellipses(&ellipses);
let mut params = flat_params_ellipse(&ellipses);
for v in &mut params {
*v += rng.random_range(-0.04..0.04);
}
assert_analytic_matches_fd_shape::<Ellipse, _>(
&spec,
¶ms,
crate::loss::LossType::NormalizedSumSquared,
1e-6,
1e-2,
|| format!("ellipse random n={}, seed={}", n, seed),
);
}
}
#[test]
#[ignore]
fn benchmark_lbfgs_analytic_vs_fd() {
use crate::loss::LossType;
use std::time::Instant;
let configs: &[(usize, u64)] = &[(5, 33), (5, 44), (8, 55)];
for &(n, seed) in configs {
use rand::Rng;
use rand::SeedableRng;
let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
let circles: Vec<Circle> = (0..n)
.map(|_| {
Circle::new(
Point::new(rng.random_range(-3.0..3.0), rng.random_range(-3.0..3.0)),
rng.random_range(0.6..1.8),
)
})
.collect();
let spec = spec_from_circles(&circles);
let positions: Vec<f64> = circles
.iter()
.flat_map(|c| [c.center().x(), c.center().y()])
.collect();
let radii: Vec<f64> = circles.iter().map(|c| c.radius()).collect();
let cfg_an = FinalLayoutConfig {
optimizer: Optimizer::Lbfgs,
loss_type: LossType::NormalizedSumSquared,
max_iterations: 200,
tolerance: 1e-6,
seed: 0,
n_restarts: 1,
cmaes_fallback_threshold: 1e-3,
};
let t0 = Instant::now();
let (_p_an, loss_an) =
optimize_layout::<Circle>(&spec, &positions, &radii, cfg_an).expect("analytic fit");
let dt_an = t0.elapsed();
let cfg_fd = FinalLayoutConfig {
optimizer: Optimizer::Lbfgs,
loss_type: LossType::SumAbsoute,
max_iterations: 200,
tolerance: 1e-6,
seed: 0,
n_restarts: 1,
cmaes_fallback_threshold: 1e-3,
};
let t0 = Instant::now();
let (_p_fd, loss_fd) =
optimize_layout::<Circle>(&spec, &positions, &radii, cfg_fd).expect("fd fit");
let dt_fd = t0.elapsed();
eprintln!(
"n={} seed={}: analytic={:>6.1}ms loss={:.4e} | fd-loss={:>6.1}ms loss={:.4e}",
n,
seed,
dt_an.as_secs_f64() * 1000.0,
loss_an,
dt_fd.as_secs_f64() * 1000.0,
loss_fd
);
}
}
#[test]
fn analytic_gradient_random_layouts() {
use rand::Rng;
use rand::SeedableRng;
let configs: &[(usize, u64)] = &[(3, 11), (3, 22), (5, 33), (5, 44), (8, 55)];
for &(n, seed) in configs {
let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
let circles: Vec<Circle> = (0..n)
.map(|_| {
Circle::new(
Point::new(rng.random_range(-3.0..3.0), rng.random_range(-3.0..3.0)),
rng.random_range(0.6..1.8),
)
})
.collect();
let spec = spec_from_circles(&circles);
let mut params = flat_params(&circles);
for v in &mut params {
*v += rng.random_range(-0.05..0.05);
}
assert_analytic_matches_fd(
&spec,
¶ms,
crate::loss::LossType::NormalizedSumSquared,
1e-6,
5e-3,
|| format!("random n={}, seed={}", n, seed),
);
}
}
#[test]
fn lm_jacobian_consistent_with_diagram_gradient() {
use rand::Rng;
use rand::SeedableRng;
let configs: &[(usize, u64, crate::loss::LossType)] = &[
(3, 11, crate::loss::LossType::SumSquared),
(3, 22, crate::loss::LossType::NormalizedSumSquared),
(5, 33, crate::loss::LossType::NormalizedSumSquared),
];
for &(n, seed, loss_type) in configs {
let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
let circles: Vec<Circle> = (0..n)
.map(|_| {
Circle::new(
Point::new(rng.random_range(-3.0..3.0), rng.random_range(-3.0..3.0)),
rng.random_range(0.6..1.8),
)
})
.collect();
let spec = spec_from_circles(&circles);
let mut params = flat_params(&circles);
for v in &mut params {
*v += rng.random_range(-0.05..0.05);
}
let param_dvec = DVector::from_vec(params.clone());
let cost = DiagramCost::<Circle> {
spec: &spec,
loss_type,
params_per_shape: Circle::n_params(),
_shape: std::marker::PhantomData,
};
let analytic_grad = cost.gradient(¶m_dvec).unwrap();
let mut problem =
LmDiagramProblem::<Circle>::new(&spec, Circle::n_params(), loss_type, ¶m_dvec)
.unwrap();
problem.set_params(¶m_dvec);
let r = problem.residuals().unwrap();
let j = problem.jacobian().unwrap();
let lm_grad = j.transpose() * &r * 2.0;
let max_abs_diff = analytic_grad
.iter()
.zip(lm_grad.iter())
.map(|(a, b)| (a - b).abs())
.fold(0.0_f64, f64::max);
let max_abs = analytic_grad
.iter()
.map(|x: &f64| x.abs())
.fold(0.0_f64, f64::max);
assert!(
max_abs_diff < 1e-9 + 1e-8 * max_abs,
"n={} seed={} loss={:?}: |2·Jᵀ·r − ∇L| = {:.3e} (max |∇L| = {:.3e})",
n,
seed,
loss_type,
max_abs_diff,
max_abs
);
}
}
#[test]
fn lm_rejects_non_least_squares_loss() {
let circles = vec![
Circle::new(Point::new(0.0, 0.0), 1.0),
Circle::new(Point::new(1.5, 0.0), 1.0),
];
let spec = spec_from_circles(&circles);
let params = DVector::from_vec(flat_params(&circles));
let result = LmDiagramProblem::<Circle>::new(
&spec,
Circle::n_params(),
crate::loss::LossType::Stress,
¶ms,
);
assert!(result.is_err(), "Stress loss should be rejected by LM");
}
}