use finitediff::vec;
use nalgebra::{DMatrix, DVector};
use rand::rngs::StdRng;
use rand::{RngExt, SeedableRng};
use std::cell::RefCell;
use std::collections::HashMap;
use crate::error::DiagramError;
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,
CmaEsLm,
Trf,
#[default]
CmaEsTrf,
}
#[derive(Debug, Clone)]
pub(crate) struct FinalLayoutConfig {
pub max_iterations: usize,
pub loss_type: crate::loss::LossType,
pub optimizer: Optimizer,
pub tolerance: f64,
pub xtol: Option<f64>,
pub ftol: Option<f64>,
pub gtol: Option<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-3,
xtol: None,
ftol: None,
gtol: None,
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], initial_rotations: Option<&[f64]>, config: FinalLayoutConfig,
) -> Result<(Vec<f64>, f64), DiagramError> {
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<DiagramError> = 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 + 4);
for i in 0..n_sets {
let x = positions[i * 2];
let y = positions[i * 2 + 1];
let r = initial_radii[i];
let mut p = S::optimizer_params_from_circle(x, y, r);
if params_per_shape == 5
&& let Some(rotations) = initial_rotations
&& let Some(&theta) = rotations.get(i)
{
p[4] = theta;
}
initial_params.extend(p);
}
if spec.complement.is_some() {
let mut sx = 0.0;
let mut sy = 0.0;
for i in 0..n_sets {
sx += positions[i * 2];
sy += positions[i * 2 + 1];
}
let cx = if n_sets > 0 { sx / n_sets as f64 } else { 0.0 };
let cy = if n_sets > 0 { sy / n_sets as f64 } else { 0.0 };
let universe = spec
.exclusive_areas
.values()
.sum::<f64>()
.max(f64::MIN_POSITIVE);
let side = universe.sqrt();
let rect = crate::geometry::shapes::Rectangle::new(
crate::geometry::primitives::Point::new(cx, cy),
side,
side,
);
initial_params.extend(rect.to_optimizer_params());
}
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(|| {
DiagramError::InvalidCombination(
"all optimization restarts failed (panicked or errored)".to_string(),
)
})),
}
}
pub(crate) fn optimize_from_initial<S: DiagramShape + Copy + 'static>(
spec: &PreprocessedSpec,
initial_param: &DVector<f64>,
config: &FinalLayoutConfig,
) -> Result<(DVector<f64>, f64), DiagramError> {
let params_per_shape = S::n_params();
let optimizer = if spec.complement.is_some() {
Optimizer::Lbfgs
} else {
config.optimizer
};
let (final_params, loss) = match optimizer {
Optimizer::NelderMead => {
run_nelder_mead::<S>(spec, params_per_shape, initial_param, config)?
}
Optimizer::Lbfgs => {
run_lbfgs::<S>(spec, params_per_shape, initial_param, config)
}
Optimizer::LevenbergMarquardt => {
run_lm_or_lbfgs::<S>(spec, params_per_shape, initial_param, config)?
}
Optimizer::Trf => run_trf::<S>(spec, params_per_shape, initial_param, config)?,
Optimizer::CmaEsLm => run_cmaes_escape::<S>(
spec,
params_per_shape,
initial_param,
config,
None,
run_lm_or_lbfgs::<S>,
)?,
Optimizer::CmaEsTrf => run_cmaes_escape::<S>(
spec,
params_per_shape,
initial_param,
config,
Some(run_trf::<S>),
run_trf::<S>,
)?,
};
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), DiagramError> {
match LmDiagramProblem::<S>::new(spec, params_per_shape, config.loss_type) {
Ok(problem) => {
let solver = basin::LevenbergMarquardt::new()
.tol_grad(0.0)
.tol_grad_rel(config.gtol.unwrap_or(1e-8))
.ftol(config.ftol.unwrap_or(config.tolerance))
.xtol(config.xtol.unwrap_or(1e-6));
let result = basin::Executor::new(
problem,
solver,
basin::BasicState::new(initial_param.clone()),
)
.max_iter(config.max_iterations.max(1) as u64)
.run();
let final_param = result.param().clone();
let final_loss = result.cost() * 2.0;
Ok((final_param, final_loss))
}
Err(_incompatible_loss) if !config.loss_type.is_smooth() => {
run_nelder_mead::<S>(spec, params_per_shape, initial_param, config)
}
Err(_incompatible_loss) => {
Ok(run_lbfgs::<S>(
spec,
params_per_shape,
initial_param,
config,
))
}
}
}
fn run_trf<S: DiagramShape + Copy + 'static>(
spec: &PreprocessedSpec,
params_per_shape: usize,
initial_param: &DVector<f64>,
config: &FinalLayoutConfig,
) -> Result<(DVector<f64>, f64), DiagramError> {
match LmDiagramProblem::<S>::new(spec, params_per_shape, config.loss_type) {
Ok(inner) => {
let (lower, upper, _std) = optimizer_bounds_for(
initial_param.as_slice(),
params_per_shape,
BoundsEnvelope::LOCAL,
);
let lower = DVector::from_vec(lower);
let upper = DVector::from_vec(upper);
let mut x0 = initial_param.clone();
for i in 0..x0.len() {
let (lo, hi) = (lower[i], upper[i]);
if lo.is_finite() && hi.is_finite() && hi > lo {
let margin = 1e-6 * (hi - lo);
x0[i] = x0[i].clamp(lo + margin, hi - margin);
}
}
let problem = BoundedLmDiagramProblem::<S> {
inner,
lower,
upper,
};
let solver = basin::Trf::<DVector<f64>, DMatrix<f64>>::new()
.tol_grad(config.gtol.unwrap_or(1e-8));
let result = basin::Executor::new(problem, solver, basin::BasicState::new(x0))
.max_iter(config.max_iterations.max(1) as u64)
.run();
let final_param = result.param().clone();
let final_loss = result.cost() * 2.0;
Ok((final_param, final_loss))
}
Err(_incompatible_loss) if !config.loss_type.is_smooth() => {
run_nelder_mead::<S>(spec, params_per_shape, initial_param, config)
}
Err(_incompatible_loss) => Ok(run_lbfgs::<S>(
spec,
params_per_shape,
initial_param,
config,
)),
}
}
type FinalSolver = fn(
&PreprocessedSpec,
usize,
&DVector<f64>,
&FinalLayoutConfig,
) -> Result<(DVector<f64>, f64), DiagramError>;
fn run_cmaes_escape<S: DiagramShape + Copy + 'static>(
spec: &PreprocessedSpec,
params_per_shape: usize,
initial_param: &DVector<f64>,
config: &FinalLayoutConfig,
baseline_extra: Option<FinalSolver>,
polish: FinalSolver,
) -> Result<(DVector<f64>, f64), DiagramError> {
let lm_only = run_lm_or_lbfgs::<S>(spec, params_per_shape, initial_param, config)?;
if lm_only.1 <= config.cmaes_fallback_threshold {
return Ok(lm_only);
}
let mut baseline = lm_only;
if let Some(extra) = baseline_extra {
let alt = extra(spec, params_per_shape, initial_param, config)?;
if alt.1 < baseline.1 {
baseline = alt;
}
if baseline.1 <= config.cmaes_fallback_threshold {
return Ok(baseline);
}
}
let cma_seed = run_bounded_cmaes::<S>(spec, params_per_shape, initial_param, config);
let cma_polish = polish(spec, params_per_shape, &cma_seed, config)?;
Ok(if cma_polish.1 < baseline.1 {
cma_polish
} else {
baseline
})
}
fn run_nelder_mead<S: DiagramShape + Copy + 'static>(
spec: &PreprocessedSpec,
params_per_shape: usize,
initial_param: &DVector<f64>,
config: &FinalLayoutConfig,
) -> Result<(DVector<f64>, f64), DiagramError> {
let n_params = initial_param.len();
let mut simplex: Vec<Vec<f64>> = Vec::with_capacity(n_params + 1);
simplex.push(initial_param.as_slice().to_vec());
for i in 0..n_params {
let mut perturbed = initial_param.as_slice().to_vec();
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);
}
let cost_function = BasinDiagramCost::<S> {
inner: DiagramCost {
spec,
loss_type: config.loss_type,
params_per_shape,
_shape: std::marker::PhantomData,
},
};
let state = basin::BasicSimplexState::from_simplex(simplex);
let solver = basin::NelderMead::standard();
let result = basin::Executor::new(cost_function, solver, state)
.max_iter(config.max_iterations as u64)
.run();
Ok((DVector::from_vec(result.param().clone()), result.cost()))
}
fn run_lbfgs<S: DiagramShape + Copy + 'static>(
spec: &PreprocessedSpec,
params_per_shape: usize,
initial_param: &DVector<f64>,
config: &FinalLayoutConfig,
) -> (DVector<f64>, f64) {
let cost_function = BasinDiagramCost::<S> {
inner: DiagramCost {
spec,
loss_type: config.loss_type,
params_per_shape,
_shape: std::marker::PhantomData,
},
};
let x0 = initial_param.as_slice().to_vec();
let solver = basin::LBFGS::<basin::solver::lbfgs::Unbounded>::new().m_capacity(10);
let result = basin::Executor::new(cost_function, solver, basin::LbfgsState::new(x0, 10))
.max_iter(config.max_iterations.max(1) as u64)
.terminate_on(basin::GradientTolerance(config.tolerance))
.terminate_on(basin::CostTolerance::new(config.tolerance))
.run();
(DVector::from_vec(result.param().clone()), result.cost())
}
fn run_bounded_cmaes<S: DiagramShape + Copy + 'static>(
spec: &PreprocessedSpec,
params_per_shape: usize,
initial_param: &DVector<f64>,
config: &FinalLayoutConfig,
) -> DVector<f64> {
let (lower, upper, initial_std) = optimizer_bounds_for(
initial_param.as_slice(),
params_per_shape,
BoundsEnvelope::CMAES,
);
let problem = BoundedDiagramCost::<S> {
inner: DiagramCost {
spec,
loss_type: config.loss_type,
params_per_shape,
_shape: std::marker::PhantomData,
},
lower: DVector::from_vec(lower),
upper: DVector::from_vec(upper),
};
let n = initial_param.len();
let lambda = basin::BoundedCmaEs::<DVector<f64>, DMatrix<f64>>::default_lambda(n);
let solver = basin::BoundedCmaEs::<DVector<f64>, DMatrix<f64>>::new(
initial_param.clone(),
1.0,
config.seed.wrapping_mul(0x9E37_79B9_7F4A_7C15),
)
.with_stds(DVector::from_vec(initial_std));
let result = basin::Executor::new(
problem,
solver,
basin::BasicPopulationState::<DVector<f64>>::with_size(lambda),
)
.max_iter(100)
.run();
result.param().clone()
}
struct BasinDiagramCost<'a, S: DiagramShape + Copy + 'static> {
inner: DiagramCost<'a, S>,
}
impl<S: DiagramShape + Copy + 'static> basin::CostFunction for BasinDiagramCost<'_, S> {
type Param = Vec<f64>;
type Output = f64;
fn cost(&self, param: &Vec<f64>) -> f64 {
let p = DVector::from_vec(param.clone());
match self.inner.cost(&p) {
Ok(c) if c.is_finite() => c,
_ => f64::INFINITY,
}
}
}
impl<S: DiagramShape + Copy + 'static> basin::Gradient for BasinDiagramCost<'_, S> {
type Param = Vec<f64>;
type Gradient = Vec<f64>;
fn gradient(&self, param: &Vec<f64>) -> Vec<f64> {
let p = DVector::from_vec(param.clone());
match self.inner.gradient(&p) {
Ok(g) => g.as_slice().to_vec(),
Err(_) => vec![0.0; param.len()],
}
}
}
struct BoundedDiagramCost<'a, S: DiagramShape + Copy + 'static> {
inner: DiagramCost<'a, S>,
lower: DVector<f64>,
upper: DVector<f64>,
}
impl<S: DiagramShape + Copy + 'static> basin::CostFunction for BoundedDiagramCost<'_, S> {
type Param = DVector<f64>;
type Output = f64;
fn cost(&self, param: &DVector<f64>) -> f64 {
match self.inner.cost(param) {
Ok(c) if c.is_finite() => c,
_ => f64::INFINITY,
}
}
}
impl<S: DiagramShape + Copy + 'static> basin::BoxConstraints for BoundedDiagramCost<'_, S> {
fn lower(&self) -> &DVector<f64> {
&self.lower
}
fn upper(&self) -> &DVector<f64> {
&self.upper
}
}
struct BoundedLmDiagramProblem<'a, S: DiagramShape + Copy + 'static> {
inner: LmDiagramProblem<'a, S>,
lower: DVector<f64>,
upper: DVector<f64>,
}
impl<S: DiagramShape + Copy + 'static> basin::Residual for BoundedLmDiagramProblem<'_, S> {
type Param = DVector<f64>;
type Output = DVector<f64>;
fn residual(&self, x: &DVector<f64>) -> DVector<f64> {
basin::Residual::residual(&self.inner, x)
}
}
impl<S: DiagramShape + Copy + 'static> basin::Jacobian for BoundedLmDiagramProblem<'_, S> {
type Param = DVector<f64>;
type Output = DMatrix<f64>;
fn jacobian(&self, x: &DVector<f64>) -> DMatrix<f64> {
basin::Jacobian::jacobian(&self.inner, x)
}
}
impl<S: DiagramShape + Copy + 'static> basin::CostFunction for BoundedLmDiagramProblem<'_, S> {
type Param = DVector<f64>;
type Output = f64;
fn cost(&self, x: &DVector<f64>) -> f64 {
0.5 * basin::Residual::residual(&self.inner, x).norm_squared()
}
}
impl<S: DiagramShape + Copy + 'static> basin::BoxConstraints for BoundedLmDiagramProblem<'_, S> {
fn lower(&self) -> &DVector<f64> {
&self.lower
}
fn upper(&self) -> &DVector<f64> {
&self.upper
}
}
#[derive(Clone, Copy)]
struct BoundsEnvelope {
pos_margin_scale: f64,
radius_floor_frac: f64,
radius_ceil_mult: f64,
}
impl BoundsEnvelope {
const CMAES: Self = Self {
pos_margin_scale: 4.0,
radius_floor_frac: 1e-6,
radius_ceil_mult: 5.0,
};
const LOCAL: Self = Self {
pos_margin_scale: 8.0,
radius_floor_frac: 1e-8,
radius_ceil_mult: 12.0,
};
}
fn optimizer_bounds_for(
initial_param: &[f64],
params_per_shape: usize,
env: BoundsEnvelope,
) -> (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 = env.pos_margin_scale * 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(env.radius_floor_frac * max_radius);
upper.push(env.radius_ceil_mult * max_radius);
std_dev.push((max_radius * 0.5).max(1e-6));
}
5 => {
let u_lower = (env.radius_floor_frac * max_radius).ln();
let u_upper = (env.radius_ceil_mult * 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<S: DiagramShape + Copy + 'static> DiagramCost<'_, 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_optimizer_params(¶ms.as_slice()[start..end])
})
.collect()
}
fn params_to_container(&self, params: &DVector<f64>) -> crate::geometry::shapes::Rectangle {
let n_sets = self.spec.n_sets;
let trailing = ¶ms.as_slice()[n_sets * self.params_per_shape..];
crate::geometry::shapes::Rectangle::from_optimizer_params(trailing)
}
}
impl<S: DiagramShape + Copy + 'static> DiagramCost<'_, S> {
fn cost(&self, param: &DVector<f64>) -> Result<f64, DiagramError> {
let shapes = self.params_to_shapes(param);
let exclusive_areas = if self.spec.complement.is_some() {
let container = self.params_to_container(param);
S::compute_exclusive_regions_clipped(&shapes, &container).ok_or_else(|| {
DiagramError::InvalidCombination(
"complement specs require a shape with `compute_exclusive_regions_clipped`; \
fitter construction should have rejected this combination"
.to_string(),
)
})?
} else {
S::compute_exclusive_regions(&shapes)
};
Ok(self
.loss_type
.compute(&exclusive_areas, &self.spec.exclusive_areas))
}
fn gradient(&self, param: &DVector<f64>) -> Result<DVector<f64>, DiagramError> {
let shapes = self.params_to_shapes(param);
let analytic = if self.spec.complement.is_some() {
let container = self.params_to_container(param);
S::compute_exclusive_regions_clipped_with_gradient(&shapes, &container)
} else {
S::compute_exclusive_regions_with_gradient(&shapes)
};
if let Some((fitted, fitted_grads)) = analytic
&& 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).map_err(|e| {
DiagramError::InvalidCombination(format!("finite-difference gradient failed: {e}"))
})?;
Ok(DVector::from_vec(grad_vec))
}
}
struct LmDiagramProblem<'a, S: DiagramShape + Copy + 'static> {
spec: &'a PreprocessedSpec,
params_per_shape: usize,
norm_factor: f64,
cache: RefCell<Option<(Vec<f64>, DiagramCache)>>,
_shape: std::marker::PhantomData<S>,
}
struct DiagramCache {
masks: Vec<RegionMask>,
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,
) -> Result<Self, DiagramError> {
let norm_factor = match loss_type {
LossType::SumSquared => {
let sum_t2: f64 = spec.exclusive_areas.values().map(|&v| v * v).sum();
if sum_t2 < 1e-20 {
return Err(DiagramError::InvalidCombination(
"Levenberg-Marquardt: target area norm is zero, cannot normalise"
.to_string(),
));
}
1.0 / sum_t2.sqrt()
}
other => {
return Err(DiagramError::InvalidCombination(format!(
"Levenberg-Marquardt only supports SumSquared loss, got {other:?}"
)));
}
};
Ok(Self {
spec,
params_per_shape,
norm_factor,
cache: RefCell::new(None),
_shape: std::marker::PhantomData,
})
}
fn ensure_cache(&self, xs: &[f64]) {
if let Some((cached_x, _)) = self.cache.borrow().as_ref()
&& cached_x.as_slice() == xs
{
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_optimizer_params(&xs[start..end])
})
.collect();
let analytic = if self.spec.complement.is_some() {
let trailing = &xs[n_sets * self.params_per_shape..];
let container = crate::geometry::shapes::Rectangle::from_optimizer_params(trailing);
S::compute_exclusive_regions_clipped_with_gradient(&shapes, &container)
} else {
S::compute_exclusive_regions_with_gradient(&shapes)
};
let (fitted, fitted_grads) = match analytic {
Some(pair) => pair,
None => {
let fitted = if self.spec.complement.is_some() {
let trailing = &xs[n_sets * self.params_per_shape..];
let container =
crate::geometry::shapes::Rectangle::from_optimizer_params(trailing);
S::compute_exclusive_regions_clipped(&shapes, &container)
.unwrap_or_else(|| S::compute_exclusive_regions(&shapes))
} else {
S::compute_exclusive_regions(&shapes)
};
(fitted, HashMap::new())
}
};
let mut mask_set: std::collections::BTreeSet<RegionMask> = self
.spec
.exclusive_areas
.keys()
.copied()
.chain(fitted.keys().copied())
.collect();
if self.spec.complement.is_none() {
mask_set.remove(&0);
}
let masks: Vec<RegionMask> = mask_set.into_iter().collect();
*self.cache.borrow_mut() = Some((
xs.to_vec(),
DiagramCache {
masks,
fitted,
fitted_grads,
},
));
}
}
impl<S: DiagramShape + Copy + 'static> basin::Residual for LmDiagramProblem<'_, S> {
type Param = DVector<f64>;
type Output = DVector<f64>;
fn residual(&self, x: &DVector<f64>) -> DVector<f64> {
self.ensure_cache(x.as_slice());
let slot = self.cache.borrow();
let (_, cache) = slot.as_ref().expect("cache populated by ensure_cache");
DVector::from_fn(cache.masks.len(), |i, _| {
let mask = cache.masks[i];
let f = cache.fitted.get(&mask).copied().unwrap_or(0.0);
let t = self.spec.exclusive_areas.get(&mask).copied().unwrap_or(0.0);
self.norm_factor * (f - t)
})
}
}
impl<S: DiagramShape + Copy + 'static> basin::Jacobian for LmDiagramProblem<'_, S> {
type Param = DVector<f64>;
type Output = DMatrix<f64>;
fn jacobian(&self, x: &DVector<f64>) -> DMatrix<f64> {
self.ensure_cache(x.as_slice());
let slot = self.cache.borrow();
let (_, cache) = slot.as_ref().expect("cache populated by ensure_cache");
let n_params = x.len();
let mut jac = DMatrix::zeros(cache.masks.len(), n_params);
for (i, &mask) in cache.masks.iter().enumerate() {
if let Some(grad) = cache.fitted_grads.get(&mask) {
for (j, &g) in grad.iter().enumerate().take(n_params) {
jac[(i, j)] = self.norm_factor * g;
}
}
}
jac
}
}
#[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::RngExt;
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,
xtol: None,
ftol: None,
gtol: None,
};
let result = optimize_layout::<Circle>(&preprocessed, &positions, &radii, None, 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,
xtol: None,
ftol: None,
gtol: None,
};
let result = optimize_layout::<Circle>(&preprocessed, &positions, &radii, None, 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,
xtol: None,
ftol: None,
gtol: None,
};
let result = optimize_layout::<Circle>(&preprocessed, &positions, &radii, None, 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,
xtol: None,
ftol: None,
gtol: None,
};
let result = optimize_layout::<Circle>(&preprocessed, &positions, &radii, None, 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
}
fn smooth_losses_for_grad_test() -> Vec<(crate::loss::LossType, f64, &'static str)> {
use crate::loss::LossType;
vec![
(LossType::SumSquared, 1.0, "SumSquared"),
(LossType::RootMeanSquared, 1.0, "RootMeanSquared"),
(LossType::Stress, 1.0, "Stress"),
(
LossType::SumSquaredRegionError,
10.0,
"SumSquaredRegionError",
),
(
LossType::smooth_sum_absolute(0.05),
10.0,
"SmoothSumAbsolute",
),
(
LossType::smooth_sum_absolute_region_error(1e-3),
10.0,
"SmoothSumAbsoluteRegionError",
),
(
LossType::smooth_max_absolute(0.5),
10.0,
"SmoothMaxAbsolute",
),
(LossType::smooth_max_squared(0.5), 10.0, "SmoothMaxSquared"),
(LossType::smooth_diag_error(1e-3), 10.0, "SmoothDiagError"),
]
}
#[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];
let base_tol = 1e-4;
for (loss_type, mul, name) in smooth_losses_for_grad_test() {
assert_analytic_matches_fd(&spec, ¶ms, loss_type, 1e-6, base_tol * mul, || {
format!("two_circle_overlap {}", name)
});
}
}
#[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;
let base_tol = 1e-3;
for (loss_type, mul, name) in smooth_losses_for_grad_test() {
assert_analytic_matches_fd(&spec, ¶ms, loss_type, 1e-6, base_tol * mul, || {
format!("three_circle_venn {}", name)
});
}
}
#[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;
let base_tol = 1e-4;
for (loss_type, mul, name) in smooth_losses_for_grad_test() {
assert_analytic_matches_fd(&spec, &perturbed, loss_type, 1e-6, base_tol * mul, || {
format!("disjoint {}", name)
});
}
}
#[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;
let base_tol = 1e-4;
for (loss_type, mul, name) in smooth_losses_for_grad_test() {
assert_analytic_matches_fd(&spec, ¶ms, loss_type, 1e-6, base_tol * mul, || {
format!("nested {}", name)
});
}
}
fn complement_spec_from_layout(
circles: &[Circle],
container: &crate::geometry::shapes::Rectangle,
) -> PreprocessedSpec {
use crate::geometry::shapes::circle::compute_exclusive_regions_clipped;
use crate::spec::{DiagramSpec, DiagramSpecBuilder};
let names: Vec<String> = (0..circles.len()).map(|i| format!("S{}", i)).collect();
let areas = compute_exclusive_regions_clipped(circles, container);
let mut builder = DiagramSpecBuilder::new();
for (i, name) in names.iter().enumerate() {
let single_mask = 1usize << i;
let v = areas.get(&single_mask).copied().unwrap_or(0.0);
builder = builder.set(name.as_str(), v);
}
for (mask, &v) in &areas {
if *mask == 0 {
continue;
}
let bits = mask.count_ones();
if bits < 2 {
continue;
}
let mut sets: Vec<&str> = Vec::with_capacity(bits as usize);
for (i, name) in names.iter().enumerate() {
if mask & (1 << i) != 0 {
sets.push(name.as_str());
}
}
builder = builder.intersection(&sets, v);
}
let complement = areas.get(&0).copied().unwrap_or(0.0);
builder = builder
.complement(complement)
.input_type(crate::InputType::Exclusive);
let spec: DiagramSpec = builder.build().unwrap();
spec.preprocess().unwrap()
}
fn pack_complement_params(
circles: &[Circle],
container: &crate::geometry::shapes::Rectangle,
) -> Vec<f64> {
let mut p = Vec::with_capacity(circles.len() * 3 + 4);
for c in circles {
p.extend(c.to_optimizer_params());
}
p.extend(container.to_optimizer_params());
p
}
#[test]
fn analytic_gradient_complement_two_circles_inside_box() {
let circles = vec![
Circle::new(Point::new(-0.6, 0.0), 1.0),
Circle::new(Point::new(0.55, 0.05), 0.95),
];
let container = crate::geometry::shapes::Rectangle::new(Point::new(0.0, 0.0), 6.0, 5.0);
let spec = complement_spec_from_layout(&circles, &container);
let mut params = pack_complement_params(&circles, &container);
params[0] += 0.07; params[3] += -0.05; params[6 + 2] += 0.03;
let cost = DiagramCost::<Circle> {
spec: &spec,
loss_type: crate::loss::LossType::SumSquared,
params_per_shape: Circle::n_params(),
_shape: std::marker::PhantomData,
};
let p = DVector::from_vec(params.clone());
let analytic = cost.gradient(&p).expect("analytic gradient");
let h = 1e-6;
let n = params.len();
let mut fd = vec![0.0; n];
for i in 0..n {
let mut plus = params.clone();
let mut minus = params.clone();
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-9 {
diff_norm / fd_norm
} else {
diff_norm
};
assert!(
rel < 1e-3,
"complement gradient mismatch (rel={:.3e}, |fd|={:.3e})\n analytic={:?}\n fd ={:?}",
rel,
fd_norm,
analytic_slice,
fd
);
}
#[test]
fn analytic_gradient_complement_one_disk_clipped() {
let circles = vec![
Circle::new(Point::new(1.4, 0.05), 0.9),
Circle::new(Point::new(0.0, -0.07), 0.85),
];
let container = crate::geometry::shapes::Rectangle::new(Point::new(0.0, 0.0), 3.6, 3.0);
let spec = complement_spec_from_layout(&circles, &container);
let mut params = pack_complement_params(&circles, &container);
params[6] += 0.04;
params[6 + 3] += 0.02;
let cost = DiagramCost::<Circle> {
spec: &spec,
loss_type: crate::loss::LossType::SumSquared,
params_per_shape: Circle::n_params(),
_shape: std::marker::PhantomData,
};
let p = DVector::from_vec(params.clone());
let analytic = cost.gradient(&p).expect("analytic gradient");
let h = 1e-6;
let n = params.len();
let mut fd = vec![0.0; n];
for i in 0..n {
let mut plus = params.clone();
let mut minus = params.clone();
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-9 {
diff_norm / fd_norm
} else {
diff_norm
};
assert!(
rel < 1e-3,
"complement+clip gradient mismatch (rel={:.3e}, |fd|={:.3e})\n analytic={:?}\n fd ={:?}",
rel,
fd_norm,
analytic_slice,
fd
);
}
fn complement_spec_from_layout_ellipse(
ellipses: &[crate::geometry::shapes::Ellipse],
container: &crate::geometry::shapes::Rectangle,
) -> PreprocessedSpec {
use crate::geometry::shapes::ellipse::compute_exclusive_regions_clipped_ellipse;
use crate::spec::{DiagramSpec, DiagramSpecBuilder};
let names: Vec<String> = (0..ellipses.len()).map(|i| format!("S{}", i)).collect();
let areas = compute_exclusive_regions_clipped_ellipse(ellipses, container);
let mut builder = DiagramSpecBuilder::new();
for (i, name) in names.iter().enumerate() {
let single_mask = 1usize << i;
let v = areas.get(&single_mask).copied().unwrap_or(0.0);
builder = builder.set(name.as_str(), v);
}
for (mask, &v) in &areas {
if *mask == 0 {
continue;
}
let bits = mask.count_ones();
if bits < 2 {
continue;
}
let mut sets: Vec<&str> = Vec::with_capacity(bits as usize);
for (i, name) in names.iter().enumerate() {
if mask & (1 << i) != 0 {
sets.push(name.as_str());
}
}
builder = builder.intersection(&sets, v);
}
let complement = areas.get(&0).copied().unwrap_or(0.0);
builder = builder
.complement(complement)
.input_type(crate::InputType::Exclusive);
let spec: DiagramSpec = builder.build().unwrap();
spec.preprocess().unwrap()
}
fn pack_complement_params_ellipse(
ellipses: &[crate::geometry::shapes::Ellipse],
container: &crate::geometry::shapes::Rectangle,
) -> Vec<f64> {
let mut p = Vec::with_capacity(ellipses.len() * 5 + 4);
for e in ellipses {
p.extend(e.to_optimizer_params());
}
p.extend(container.to_optimizer_params());
p
}
#[test]
fn analytic_gradient_complement_two_ellipses_inside_box() {
use crate::geometry::shapes::Ellipse;
let ellipses = vec![
Ellipse::new(Point::new(-0.7, 0.0), 1.0, 0.7, 0.0),
Ellipse::new(Point::new(0.55, 0.05), 0.95, 0.6, 0.0),
];
let container = crate::geometry::shapes::Rectangle::new(Point::new(0.0, 0.0), 6.0, 5.0);
let spec = complement_spec_from_layout_ellipse(&ellipses, &container);
let mut params = pack_complement_params_ellipse(&ellipses, &container);
params[0] += 0.07; params[5] += -0.05; params[10 + 2] += 0.03;
let cost = DiagramCost::<Ellipse> {
spec: &spec,
loss_type: crate::loss::LossType::SumSquared,
params_per_shape: Ellipse::n_params(),
_shape: std::marker::PhantomData,
};
let p = DVector::from_vec(params.clone());
let analytic = cost.gradient(&p).expect("analytic gradient");
let h = 1e-6;
let n = params.len();
let mut fd = vec![0.0; n];
for i in 0..n {
let mut plus = params.clone();
let mut minus = params.clone();
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-9 {
diff_norm / fd_norm
} else {
diff_norm
};
assert!(
rel < 1e-3,
"ellipse complement gradient mismatch (rel={:.3e}, |fd|={:.3e})\n analytic={:?}\n fd ={:?}",
rel,
fd_norm,
analytic_slice,
fd
);
}
#[test]
fn analytic_gradient_complement_one_ellipse_clipped() {
use crate::geometry::shapes::Ellipse;
let ellipses = vec![
Ellipse::new(Point::new(1.4, 0.05), 0.9, 0.6, 0.3),
Ellipse::new(Point::new(0.0, -0.07), 0.85, 0.55, -0.2),
];
let container = crate::geometry::shapes::Rectangle::new(Point::new(0.0, 0.0), 3.6, 3.0);
let spec = complement_spec_from_layout_ellipse(&ellipses, &container);
let mut params = pack_complement_params_ellipse(&ellipses, &container);
params[10] += 0.04;
params[10 + 3] += 0.02;
let cost = DiagramCost::<Ellipse> {
spec: &spec,
loss_type: crate::loss::LossType::SumSquared,
params_per_shape: Ellipse::n_params(),
_shape: std::marker::PhantomData,
};
let p = DVector::from_vec(params.clone());
let analytic = cost.gradient(&p).expect("analytic gradient");
let h = 1e-6;
let n = params.len();
let mut fd = vec![0.0; n];
for i in 0..n {
let mut plus = params.clone();
let mut minus = params.clone();
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-9 {
diff_norm / fd_norm
} else {
diff_norm
};
assert!(
rel < 1e-3,
"ellipse complement+clip gradient mismatch (rel={:.3e}, |fd|={:.3e})\n analytic={:?}\n fd ={:?}",
rel,
fd_norm,
analytic_slice,
fd
);
}
#[test]
#[ignore]
fn benchmark_gradient_call_only_ellipse() {
use crate::geometry::shapes::Ellipse;
use crate::loss::LossType;
use rand::{RngExt, 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::SumSquared,
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::{RngExt, 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::SumSquared,
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::Combination;
use crate::geometry::traits::Area;
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;
let base_tol = 5e-4;
for (loss_type, mul, name) in smooth_losses_for_grad_test() {
assert_analytic_matches_fd_shape::<Ellipse, _>(
&spec,
¶ms,
loss_type,
1e-6,
base_tol * mul,
|| format!("ellipse two_overlap {}", name),
);
}
}
#[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;
let base_tol = 5e-3;
for (loss_type, mul, name) in smooth_losses_for_grad_test() {
assert_analytic_matches_fd_shape::<Ellipse, _>(
&spec,
¶ms,
loss_type,
1e-6,
base_tol * mul,
|| format!("ellipse three_venn {}", name),
);
}
}
#[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;
let base_tol = 5e-4;
for (loss_type, mul, name) in smooth_losses_for_grad_test() {
assert_analytic_matches_fd_shape::<Ellipse, _>(
&spec,
¶ms,
loss_type,
1e-6,
base_tol * mul,
|| format!("ellipse disjoint {}", name),
);
}
}
#[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;
let base_tol = 5e-4;
for (loss_type, mul, name) in smooth_losses_for_grad_test() {
assert_analytic_matches_fd_shape::<Ellipse, _>(
&spec,
¶ms,
loss_type,
1e-6,
base_tol * mul,
|| format!("ellipse nested {}", name),
);
}
}
#[test]
fn analytic_gradient_ellipse_random_layouts() {
use crate::geometry::shapes::Ellipse;
use rand::RngExt;
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::SumSquared,
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::RngExt;
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::SumSquared,
max_iterations: 200,
tolerance: 1e-6,
seed: 0,
n_restarts: 1,
cmaes_fallback_threshold: 1e-3,
xtol: None,
ftol: None,
gtol: None,
};
let t0 = Instant::now();
let (_p_an, loss_an) =
optimize_layout::<Circle>(&spec, &positions, &radii, None, 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,
xtol: None,
ftol: None,
gtol: None,
};
let t0 = Instant::now();
let (_p_fd, loss_fd) =
optimize_layout::<Circle>(&spec, &positions, &radii, None, 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::RngExt;
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::SumSquared,
1e-6,
5e-3,
|| format!("random n={}, seed={}", n, seed),
);
}
}
#[test]
fn lm_jacobian_consistent_with_diagram_gradient() {
use rand::RngExt;
use rand::SeedableRng;
let configs: &[(usize, u64, crate::loss::LossType)] = &[
(3, 11, crate::loss::LossType::SumSquared),
(5, 33, crate::loss::LossType::SumSquared),
];
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 problem =
LmDiagramProblem::<Circle>::new(&spec, Circle::n_params(), loss_type).unwrap();
let r = basin::Residual::residual(&problem, ¶m_dvec);
let j = basin::Jacobian::jacobian(&problem, ¶m_dvec);
let n_params = param_dvec.len();
let n_res = r.nrows();
let lm_grad: Vec<f64> = (0..n_params)
.map(|jx| 2.0 * (0..n_res).map(|ix| j[(ix, jx)] * r[ix]).sum::<f64>())
.collect();
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 result = LmDiagramProblem::<Circle>::new(
&spec,
Circle::n_params(),
crate::loss::LossType::Stress,
);
assert!(result.is_err(), "Stress loss should be rejected by LM");
}
}