use argmin::core::{CostFunction, Error, Executor, Gradient, Hessian, State};
use argmin::solver::conjugategradient::beta::PolakRibiere;
use argmin::solver::conjugategradient::NonlinearConjugateGradient;
use argmin::solver::linesearch::{
condition::ArmijoCondition, BacktrackingLineSearch, MoreThuenteLineSearch,
};
use argmin::solver::neldermead::NelderMead;
use argmin::solver::quasinewton::LBFGS;
use argmin::solver::simulatedannealing::{Anneal, SATempFunc, SimulatedAnnealing};
use argmin::solver::trustregion::{CauchyPoint, TrustRegion};
use finitediff::vec;
use nalgebra::DVector;
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use std::cell::RefCell;
use crate::geometry::traits::DiagramShape;
use crate::spec::PreprocessedSpec;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Optimizer {
#[default]
NelderMead,
Lbfgs,
ConjugateGradient,
TrustRegion,
SimulatedAnnealing,
}
#[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,
}
impl Default for FinalLayoutConfig {
fn default() -> Self {
Self {
max_iterations: 200,
loss_type: crate::loss::LossType::default(),
optimizer: Optimizer::Lbfgs,
tolerance: 1e-6,
seed: 0xDEAD_BEEF,
n_restarts: 10,
}
}
}
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)")
})),
}
}
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::TrustRegion => {
struct VecCostFunction<'a, S: DiagramShape + Copy + 'static> {
inner: DiagramCost<'a, S>,
}
impl<'a, S: DiagramShape + Copy + 'static> CostFunction for VecCostFunction<'a, S> {
type Param = Vec<f64>;
type Output = f64;
fn cost(&self, param: &Self::Param) -> Result<Self::Output, Error> {
let dvec = DVector::from_vec(param.clone());
self.inner.cost(&dvec)
}
}
impl<'a, S: DiagramShape + Copy + 'static> Gradient for VecCostFunction<'a, S> {
type Param = Vec<f64>;
type Gradient = Vec<f64>;
fn gradient(&self, param: &Self::Param) -> Result<Self::Gradient, Error> {
let dvec = DVector::from_vec(param.clone());
let grad = self.inner.gradient(&dvec)?;
Ok(grad.as_slice().to_vec())
}
}
impl<'a, S: DiagramShape + Copy + 'static> Hessian for VecCostFunction<'a, S> {
type Param = Vec<f64>;
type Hessian = Vec<Vec<f64>>;
fn hessian(&self, param: &Self::Param) -> Result<Self::Hessian, Error> {
let dvec = DVector::from_vec(param.clone());
self.inner.hessian(&dvec)
}
}
let inner_cost = DiagramCost::<S> {
spec,
loss_type: config.loss_type,
params_per_shape,
_shape: std::marker::PhantomData,
};
let cost_function = VecCostFunction { inner: inner_cost };
let solver = TrustRegion::new(CauchyPoint::new());
let initial_param_vec = initial_param.as_slice().to_vec();
let result = Executor::new(cost_function, solver)
.configure(|state| {
state
.param(initial_param_vec)
.max_iters(config.max_iterations as u64)
})
.run()?;
(
DVector::from_vec(result.state().get_best_param().unwrap().clone()),
result.state().get_best_cost(),
)
}
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::ConjugateGradient => {
let cost_function_cg = DiagramCost::<S> {
spec,
loss_type: config.loss_type,
params_per_shape,
_shape: std::marker::PhantomData,
};
let line_search = BacktrackingLineSearch::new(ArmijoCondition::new(0.01)?);
let beta_update = PolakRibiere::new();
let solver = NonlinearConjugateGradient::new(line_search, beta_update);
let result = Executor::new(cost_function_cg, 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::SimulatedAnnealing => {
let (lower, upper) = derive_sa_bounds(initial_param.as_slice(), params_per_shape);
let (best_params, best_loss) = run_simulated_annealing::<S>(
spec,
initial_param.as_slice(),
&lower,
&upper,
config.loss_type,
params_per_shape,
config.max_iterations,
config.seed,
)?;
(DVector::from_vec(best_params), best_loss)
}
};
Ok((final_params, loss))
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn run_simulated_annealing<S: DiagramShape + Copy + 'static>(
spec: &PreprocessedSpec,
start_params: &[f64],
lower: &[f64],
upper: &[f64],
loss_type: crate::loss::LossType,
params_per_shape: usize,
max_iters: usize,
seed: u64,
) -> Result<(Vec<f64>, f64), Error> {
let cost = BoundedDiagramCost::<S> {
inner: DiagramCost {
spec,
loss_type,
params_per_shape,
_shape: std::marker::PhantomData,
},
lower: lower.to_vec(),
upper: upper.to_vec(),
rng: RefCell::new(StdRng::seed_from_u64(seed ^ 0xA5A5_A5A5_A5A5_A5A5)),
};
let init_temp = 10.0_f64;
let solver = SimulatedAnnealing::new(init_temp)?
.with_temp_func(SATempFunc::Boltzmann)
.with_stall_best(200)
.with_stall_accepted(200);
let start_vec = start_params.to_vec();
let result = Executor::new(cost, solver)
.configure(|state| state.param(start_vec).max_iters(max_iters as u64))
.run()?;
Ok((
result.state().get_best_param().unwrap().clone(),
result.state().get_best_cost(),
))
}
pub(crate) fn derive_sa_bounds(params: &[f64], params_per_shape: usize) -> (Vec<f64>, Vec<f64>) {
let n_shapes = params.len() / params_per_shape;
let mut lower = vec![0.0; params.len()];
let mut upper = vec![0.0; params.len()];
let mut min_x = f64::INFINITY;
let mut min_y = f64::INFINITY;
let mut max_x = f64::NEG_INFINITY;
let mut max_y = f64::NEG_INFINITY;
let mut max_extent: f64 = 0.0;
for i in 0..n_shapes {
let off = i * params_per_shape;
let (h, k, xlim, ylim) = if params_per_shape == 5 {
let a = params[off + 2].abs();
let b = params[off + 3].abs();
let phi = params[off + 4];
let xlim = ((a * phi.cos()).powi(2) + (b * phi.sin()).powi(2)).sqrt();
let ylim = ((a * phi.sin()).powi(2) + (b * phi.cos()).powi(2)).sqrt();
(params[off], params[off + 1], xlim, ylim)
} else {
let r = params[off + 2].abs();
(params[off], params[off + 1], r, r)
};
min_x = min_x.min(h - xlim);
min_y = min_y.min(k - ylim);
max_x = max_x.max(h + xlim);
max_y = max_y.max(k + ylim);
max_extent = max_extent.max(xlim).max(ylim);
}
if (max_x - min_x).abs() < 1e-6 {
min_x -= 1.0;
max_x += 1.0;
}
if (max_y - min_y).abs() < 1e-6 {
min_y -= 1.0;
max_y += 1.0;
}
let pad = (max_extent * 2.0).max((max_x - min_x).max(max_y - min_y));
min_x -= pad;
min_y -= pad;
max_x += pad;
max_y += pad;
for i in 0..n_shapes {
let off = i * params_per_shape;
lower[off] = min_x;
lower[off + 1] = min_y;
upper[off] = max_x;
upper[off + 1] = max_y;
if params_per_shape == 5 {
let a = params[off + 2].abs().max(1e-6);
let b = params[off + 3].abs().max(1e-6);
lower[off + 2] = a / 5.0;
upper[off + 2] = a * 5.0;
lower[off + 3] = b / 5.0;
upper[off + 3] = b * 5.0;
lower[off + 4] = 0.0;
upper[off + 4] = std::f64::consts::PI;
} else if params_per_shape == 3 {
let r = params[off + 2].abs().max(1e-6);
lower[off + 2] = r / 5.0;
upper[off + 2] = r * 5.0;
}
}
(lower, upper)
}
pub(crate) fn diag_error_from_params<S: DiagramShape + Copy + 'static>(
params: &[f64],
spec: &PreprocessedSpec,
) -> f64 {
let n_sets = spec.n_sets;
let params_per_shape = S::n_params();
let shapes: Vec<S> = (0..n_sets)
.map(|i| {
let start = i * params_per_shape;
let end = start + params_per_shape;
S::from_params(¶ms[start..end])
})
.collect();
let fitted = S::compute_exclusive_regions(&shapes);
crate::loss::LossType::DiagError.compute(&fitted, &spec.exclusive_areas)
}
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))
}
}
struct BoundedDiagramCost<'a, S: DiagramShape + Copy + 'static> {
inner: DiagramCost<'a, S>,
lower: Vec<f64>,
upper: Vec<f64>,
rng: RefCell<StdRng>,
}
impl<'a, S: DiagramShape + Copy + 'static> CostFunction for BoundedDiagramCost<'a, S> {
type Param = Vec<f64>;
type Output = f64;
fn cost(&self, param: &Self::Param) -> Result<Self::Output, Error> {
let dvec = DVector::from_vec(param.clone());
self.inner.cost(&dvec)
}
}
impl<'a, S: DiagramShape + Copy + 'static> Anneal for BoundedDiagramCost<'a, S> {
type Param = Vec<f64>;
type Output = Vec<f64>;
type Float = f64;
fn anneal(&self, param: &Self::Param, extent: Self::Float) -> Result<Self::Output, Error> {
let mut rng = self.rng.borrow_mut();
let mut next = param.clone();
for (i, value) in next.iter_mut().enumerate() {
let range = (self.upper[i] - self.lower[i]).abs().max(1e-6);
let step = rng.random_range(-1.0..1.0) * extent * range / 20.0;
*value = (*value + step).clamp(self.lower[i], self.upper[i]);
}
Ok(next)
}
}
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)
}
}
#[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,
};
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,
};
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,
};
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,
};
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
);
}
}
#[test]
fn test_derive_sa_bounds_ellipse() {
let params = vec![
0.0, 0.0, 2.0, 1.0, 0.0, 5.0, 0.0, 2.0, 1.0, 0.0, ];
let (lower, upper) = derive_sa_bounds(¶ms, 5);
assert!((lower[0] - (-11.0)).abs() < 1e-9);
assert!((upper[0] - 16.0).abs() < 1e-9);
assert!((lower[1] - (-10.0)).abs() < 1e-9);
assert!((upper[1] - 10.0).abs() < 1e-9);
assert!((lower[2] - (2.0 / 5.0)).abs() < 1e-9);
assert!((upper[2] - 10.0).abs() < 1e-9);
assert!((lower[3] - (1.0 / 5.0)).abs() < 1e-9);
assert!((upper[3] - 5.0).abs() < 1e-9);
assert!((lower[4]).abs() < 1e-9);
assert!((upper[4] - std::f64::consts::PI).abs() < 1e-9);
}
#[test]
fn test_derive_sa_bounds_circle() {
let params = vec![0.0, 0.0, 2.0, 5.0, 0.0, 1.0];
let (lower, upper) = derive_sa_bounds(¶ms, 3);
assert!((lower[0] - (-10.0)).abs() < 1e-9);
assert!((upper[0] - 14.0).abs() < 1e-9);
assert!((lower[2] - (2.0 / 5.0)).abs() < 1e-9);
assert!((upper[2] - 10.0).abs() < 1e-9);
}
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,
};
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,
};
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 test_sa_run_finds_improvement() {
use crate::geometry::shapes::Circle;
let spec = DiagramSpecBuilder::new()
.set("A", 10.0)
.set("B", 8.0)
.intersection(&["A", "B"], 2.0)
.build()
.unwrap();
let preprocessed = spec.preprocess().unwrap();
let start = vec![0.0, 0.0, 1.78, 3.0, 0.0, 1.6];
let (lower, upper) = derive_sa_bounds(&start, 3);
let (best, loss) = run_simulated_annealing::<Circle>(
&preprocessed,
&start,
&lower,
&upper,
crate::loss::LossType::sse(),
3,
500,
42,
)
.unwrap();
assert_eq!(best.len(), start.len());
assert!(loss.is_finite());
}
}