Skip to main content

fugue_evo/inference/
pareto.rs

1//! Multi-objective optimization as Bayesian inference: the Pareto posterior
2//!
3//! Classic multi-objective EC (NSGA-II) has no scalar target, so it cannot be
4//! a posterior sampler. The Bayesian counterpart puts the scalarization
5//! weight **inside the model**: with objectives `f_1..f_k` (minimized, per
6//! the [`MultiObjectiveFitness`] convention) and a uniform prior over the
7//! weight simplex,
8//!
9//! ```text
10//!     w ~ Uniform(simplex)          (stick-breaking Beta sites)
11//!     π_β(x, w) ∝ p(x) · exp(−β · ⟨w, f(x)⟩)
12//! ```
13//!
14//! the joint posterior spreads over front-adjacent configurations: each
15//! weight vector `w` selects a scalarized optimum on the front, and each
16//! particle's trace carries its own `w` (at `pareto#v{i}` stick-breaking
17//! sites), telling you *where on the front* that particle lives — a posterior
18//! over front positions, with the usual inference dividends (uncertainty,
19//! evidence), which NSGA-II cannot express.
20//!
21//! **Marginal-tilt caveat (read this)**: in the latent-`w` model the
22//! `w`-marginal is *not* uniform — it is tilted by `exp(−s·m(w))`, where
23//! `m(w)` is the scalarized optimum's value at `w`, so weights whose optima
24//! score better attract more mass, and high sharpness or heavy annealing
25//! concentrates the population near the best-scoring front regions (often the
26//! endpoints). The *conditional* `x | w` is what tracks the front. For
27//! uniform front coverage, sweep **fixed** weights
28//! ([`ChebyshevScalarization::with_weight`]) across a grid, or keep sharpness
29//! moderate and read positions off `particle_weights`.
30//!
31//! [`ParetoScalarization`] uses weighted-sum scalarization, which recovers
32//! the convex part of the front; [`ChebyshevScalarization`] uses the weighted
33//! Chebyshev (weighted-max) norm, which reaches every (weakly)
34//! Pareto-optimal point — including non-convex front regions where every
35//! weighted-sum optimum collapses to the front's endpoints.
36
37use fugue::{addr, factor, Beta, Model, ModelExt, Trace};
38
39use super::likelihood::GenomeLikelihood;
40use crate::fitness::multi_objective::MultiObjectiveFitness;
41
42/// A scalarization likelihood with a latent weight vector: the Bayesian
43/// multi-objective target. See the [module docs](self).
44#[derive(Clone)]
45pub struct ParetoScalarization<M> {
46    /// The multi-objective fitness (objectives **minimized**).
47    pub objectives: M,
48    /// Sharpness of the scalarized likelihood, `exp(−sharpness·⟨w, f⟩)`.
49    /// Larger values concentrate particles closer to the front. (This is a
50    /// fixed model parameter; the SMC tempering β multiplies it on top.)
51    pub sharpness: f64,
52}
53
54impl<M> ParetoScalarization<M> {
55    /// Create a Pareto-posterior likelihood with the given sharpness.
56    pub fn new(objectives: M, sharpness: f64) -> Self {
57        Self {
58            objectives,
59            sharpness,
60        }
61    }
62}
63
64/// Build the uniform-simplex weight model via stick-breaking:
65/// `v_i ~ Beta(1, k−1−i)` for `i = 0..k−1` (the last stick is deterministic
66/// but sampled as `Beta(1, 1)`-degenerate skip), yielding `w` uniform on the
67/// `k`-simplex. For `k = 2` this is a single `Beta(1,1)` site.
68fn weight_model(k: usize) -> Model<Vec<f64>> {
69    fn stick(i: usize, k: usize, acc: Vec<f64>, remaining: f64) -> Model<Vec<f64>> {
70        if i == k - 1 {
71            let mut acc = acc;
72            acc.push(remaining);
73            return fugue::pure(acc);
74        }
75        let b = (k - 1 - i) as f64;
76        fugue::sample(
77            addr!("pareto", format!("v{i}")),
78            Beta::new(1.0, b).expect("valid stick-breaking Beta"),
79        )
80        .bind(move |v| {
81            let mut acc = acc;
82            let w = v * remaining;
83            acc.push(w);
84            stick(i + 1, k, acc, remaining - w)
85        })
86    }
87    stick(0, k, Vec::with_capacity(k), 1.0)
88}
89
90impl<G, M> GenomeLikelihood<G> for ParetoScalarization<M>
91where
92    G: 'static,
93    M: MultiObjectiveFitness<G> + Clone + Send + Sync + 'static,
94{
95    fn model(&self, genome: &G, beta: f64) -> Model<()> {
96        let objs = self.objectives.evaluate(genome);
97        let k = objs.len();
98        let sharpness = self.sharpness;
99        if k == 0 {
100            return fugue::pure(());
101        }
102        weight_model(k).bind(move |w| {
103            let scalarized: f64 = w.iter().zip(&objs).map(|(wi, fi)| wi * fi).sum();
104            if scalarized.is_finite() {
105                factor(-beta * sharpness * scalarized)
106            } else {
107                factor(f64::NEG_INFINITY)
108            }
109        })
110    }
111}
112
113/// The Chebyshev (weighted-max) scalarization likelihood with a latent
114/// weight vector:
115///
116/// ```text
117///     w ~ Uniform(simplex)
118///     π_β(x, w) ∝ p(x) · exp(−β · s · max_i  w_i · (f_i(x) − z_i))
119/// ```
120///
121/// where `z` is the **ideal point** (a reference component-wise ≤ the
122/// objective values of interest, e.g. per-objective minima or a slightly
123/// optimistic estimate). Minimizing the weighted Chebyshev norm over `x`
124/// reaches every weakly Pareto-optimal point as `w` varies over the simplex
125/// (Miettinen 1999) — in particular the **non-convex** front regions where a
126/// weighted sum's interior stationary point is a maximum and all its mass
127/// collapses onto the front's endpoints. Use this when the front may be
128/// non-convex; use [`ParetoScalarization`] when it is known convex (the
129/// weighted sum is smoother).
130#[derive(Clone)]
131pub struct ChebyshevScalarization<M> {
132    /// The multi-objective fitness (objectives **minimized**).
133    pub objectives: M,
134    /// Sharpness of the scalarized likelihood (see [`ParetoScalarization`]).
135    pub sharpness: f64,
136    /// The ideal/reference point `z` (one entry per objective).
137    pub ideal: Vec<f64>,
138    /// `None`: the weight is a latent site (subject to the marginal-tilt
139    /// caveat in the [module docs](self)). `Some(w)`: a fixed weight — the
140    /// posterior concentrates on that weight's own front point, which is the
141    /// mode to use for sweeping the front uniformly.
142    pub weight: Option<Vec<f64>>,
143}
144
145impl<M> ChebyshevScalarization<M> {
146    /// Create a Chebyshev-scalarization likelihood with a **latent** weight.
147    pub fn new(objectives: M, sharpness: f64, ideal: Vec<f64>) -> Self {
148        Self {
149            objectives,
150            sharpness,
151            ideal,
152            weight: None,
153        }
154    }
155
156    /// Fix the scalarization weight (front-sweeping mode): the posterior
157    /// targets this weight's own scalarized optimum — reaching interior
158    /// points of non-convex fronts that no weighted sum can select.
159    pub fn with_weight(mut self, weight: Vec<f64>) -> Self {
160        self.weight = Some(weight);
161        self
162    }
163}
164
165impl<G, M> GenomeLikelihood<G> for ChebyshevScalarization<M>
166where
167    G: 'static,
168    M: MultiObjectiveFitness<G> + Clone + Send + Sync + 'static,
169{
170    fn model(&self, genome: &G, beta: f64) -> Model<()> {
171        let objs = self.objectives.evaluate(genome);
172        let k = objs.len();
173        let sharpness = self.sharpness;
174        let ideal = self.ideal.clone();
175        if k == 0 {
176            return fugue::pure(());
177        }
178        debug_assert_eq!(ideal.len(), k, "ideal point must match objective count");
179        let cheby_factor = move |w: &[f64], objs: &[f64], ideal: &[f64]| -> Model<()> {
180            let cheby = w
181                .iter()
182                .zip(objs.iter().zip(ideal))
183                .map(|(wi, (fi, zi))| wi * (fi - zi))
184                .fold(f64::NEG_INFINITY, f64::max);
185            if cheby.is_finite() {
186                factor(-beta * sharpness * cheby)
187            } else {
188                factor(f64::NEG_INFINITY)
189            }
190        };
191        match self.weight.clone() {
192            Some(w) => cheby_factor(&w, &objs, &ideal),
193            None => weight_model(k).bind(move |w| cheby_factor(&w, &objs, &ideal)),
194        }
195    }
196}
197
198/// Read a particle's weight vector back off its trace (the stick-breaking
199/// sites), i.e. *where on the front* the particle lives. Returns `None` when
200/// the sites are absent (e.g. a prior-only trace).
201pub fn particle_weights(trace: &Trace, num_objectives: usize) -> Option<Vec<f64>> {
202    let k = num_objectives;
203    let mut w = Vec::with_capacity(k);
204    let mut remaining = 1.0;
205    for i in 0..k - 1 {
206        let v = trace.get_f64(&addr!("pareto", format!("v{i}")))?;
207        let wi = v * remaining;
208        w.push(wi);
209        remaining -= wi;
210    }
211    w.push(remaining);
212    Some(w)
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::genome::bounds::{Bounds, MultiBounds};
219    use crate::genome::real_vector::RealVector;
220    use crate::genome::traits::RealValuedGenome;
221    use crate::inference::model::EvolutionModel;
222    use crate::inference::prior::UniformBoxPrior;
223    use crate::inference::smc::{CrossoverConfig, EvoSmcConfig, EvolutionSMC};
224    use fugue::ResamplingMethod;
225    use rand::rngs::StdRng;
226    use rand::SeedableRng;
227
228    /// Analytic validation: 1-D biobjective `f1 = x²`, `f2 = (x−2)²`
229    /// (minimized). The Pareto set is exactly `[0, 2]`, and the weighted-sum
230    /// optimum for weight `w` on `f1` is `x*(w) = 2(1−w)`. The Pareto
231    /// posterior must (a) concentrate on the Pareto set, (b) cover both ends
232    /// of the front, and (c) place each particle near its own weight's
233    /// scalarized optimum.
234    #[test]
235    fn test_pareto_posterior_traces_the_front() {
236        #[derive(Clone)]
237        struct BiObjective;
238        impl MultiObjectiveFitness<RealVector> for BiObjective {
239            fn num_objectives(&self) -> usize {
240                2
241            }
242            fn evaluate(&self, g: &RealVector) -> Vec<f64> {
243                let x = g.genes()[0];
244                vec![x * x, (x - 2.0) * (x - 2.0)]
245            }
246        }
247        let objectives = BiObjective;
248        let prior = UniformBoxPrior::new(MultiBounds::new(vec![Bounds::new(-1.0, 3.0)]));
249        let model =
250            EvolutionModel::from_likelihood(prior, ParetoScalarization::new(objectives, 8.0));
251
252        let mut rng = StdRng::seed_from_u64(1618);
253        // Posterior, then anneal a little for front sharpness.
254        let result = EvolutionSMC::anneal(
255            &mut rng,
256            &model,
257            EvoSmcConfig {
258                num_particles: 500,
259                ess_threshold: 0.5,
260                resampling: ResamplingMethod::Systematic,
261                rejuvenation_steps: 5,
262                crossover: Some(CrossoverConfig::default()),
263            },
264            8.0,
265            8,
266        );
267
268        let model_fn = model.smc_model();
269        let decoded = fugue::decode_particles(&result.particles, &model_fn);
270
271        let mut on_set_mass = 0.0;
272        let mut low_end = 0.0;
273        let mut high_end = 0.0;
274        let mut w_err_sum = 0.0;
275        let mut w_err_n = 0.0;
276        for (p, (g, w)) in result.particles.iter().zip(&decoded) {
277            let x = g.genes()[0];
278            if (-0.25..=2.25).contains(&x) {
279                on_set_mass += w;
280            }
281            if x < 0.5 {
282                low_end += w;
283            }
284            if x > 1.5 {
285                high_end += w;
286            }
287            if let Some(wv) = particle_weights(&p.trace, 2) {
288                let x_star = 2.0 * (1.0 - wv[0]);
289                w_err_sum += w * (x - x_star).abs();
290                w_err_n += w;
291            }
292        }
293        assert!(
294            on_set_mass > 0.9,
295            "only {on_set_mass:.2} of posterior mass on the Pareto set [0,2]"
296        );
297        assert!(
298            low_end > 0.08 && high_end > 0.08,
299            "front ends not covered: low {low_end:.2}, high {high_end:.2}"
300        );
301        let mean_w_err = w_err_sum / w_err_n.max(1e-12);
302        assert!(
303            mean_w_err < 0.45,
304            "particles should sit near their weight's scalarized optimum; mean |x − 2(1−w)| = {mean_w_err:.3}"
305        );
306    }
307
308    /// The non-convex-front contrast, at the theorem level. Objectives
309    /// (minimized) on x ∈ [0,1]: `f1 = x`, `f2 = 1 − x²`. Every x in [0,1]
310    /// is Pareto-optimal and the front `f2 = 1 − f1²` is CONCAVE, so for any
311    /// FIXED weight the weighted-sum scalarization `w·x + (1−w)(1−x²)` has
312    /// its interior stationary point as a MAXIMUM (second derivative
313    /// −2(1−w) < 0): its minimizers are always the endpoints, and interior
314    /// front points are unreachable. The Chebyshev scalarization's fixed-w
315    /// optimum is the interior crossing point `w·x = (1−w)(1−x²)` — for
316    /// w = 1/2, x* = (√5−1)/2 ≈ 0.618. We pin both facts.
317    #[test]
318    fn test_chebyshev_reaches_nonconvex_front_where_weighted_sum_cannot() {
319        #[derive(Clone)]
320        struct ConcaveFront;
321        impl MultiObjectiveFitness<RealVector> for ConcaveFront {
322            fn num_objectives(&self) -> usize {
323                2
324            }
325            fn evaluate(&self, g: &RealVector) -> Vec<f64> {
326                let x = g.genes()[0];
327                vec![x, 1.0 - x * x]
328            }
329        }
330
331        /// Test-local fixed-weight weighted-sum likelihood (the published
332        /// ParetoScalarization is latent-w only).
333        #[derive(Clone)]
334        struct FixedWeightSum {
335            w: f64,
336            sharpness: f64,
337        }
338        impl GenomeLikelihood<RealVector> for FixedWeightSum {
339            fn model(&self, g: &RealVector, beta: f64) -> Model<()> {
340                let objs = ConcaveFront.evaluate(g);
341                let s = self.w * objs[0] + (1.0 - self.w) * objs[1];
342                factor(-beta * self.sharpness * s)
343            }
344        }
345
346        let prior = || UniformBoxPrior::new(MultiBounds::new(vec![Bounds::new(0.0, 1.0)]));
347        let cfg = || EvoSmcConfig {
348            num_particles: 500,
349            ess_threshold: 0.5,
350            resampling: ResamplingMethod::Systematic,
351            rejuvenation_steps: 5,
352            crossover: Some(CrossoverConfig::default()),
353        };
354
355        let mut rng = StdRng::seed_from_u64(271828);
356
357        // (a) Fixed w = 1/2, weighted sum: bimodal at the endpoints; the
358        // interior is a scalarization MAXIMUM and must be avoided.
359        let ws_model = EvolutionModel::from_likelihood(
360            prior(),
361            FixedWeightSum {
362                w: 0.5,
363                sharpness: 25.0,
364            },
365        );
366        let ws = EvolutionSMC::anneal(&mut rng, &ws_model, cfg(), 8.0, 8);
367        let ws_fn = ws_model.smc_model();
368        let ws_interior: f64 = fugue::decode_particles(&ws.particles, &ws_fn)
369            .iter()
370            .filter(|(g, _)| (0.25..0.75).contains(&g.genes()[0]))
371            .map(|(_, w)| w)
372            .sum();
373        assert!(
374            ws_interior < 0.1,
375            "fixed-w weighted sum put {ws_interior:.3} mass in the interior — impossible for a concave front"
376        );
377
378        // (b) Fixed w = 1/2, Chebyshev: concentrates on the interior front
379        // point x* = (√5 − 1)/2 ≈ 0.618 — the point weighted-sum cannot reach.
380        let x_star = (5.0f64.sqrt() - 1.0) / 2.0;
381        let ch_model = EvolutionModel::from_likelihood(
382            prior(),
383            ChebyshevScalarization::new(ConcaveFront, 25.0, vec![0.0, 0.0])
384                .with_weight(vec![0.5, 0.5]),
385        );
386        let ch = EvolutionSMC::anneal(&mut rng, &ch_model, cfg(), 8.0, 8);
387        let mean = ch.weighted_mean(0);
388        assert!(
389            (mean - x_star).abs() < 0.08,
390            "fixed-w Chebyshev posterior mean {mean:.3} should sit at the interior front point {x_star:.3}"
391        );
392        let ch_fn = ch_model.smc_model();
393        let ch_interior: f64 = fugue::decode_particles(&ch.particles, &ch_fn)
394            .iter()
395            .filter(|(g, _)| (0.25..0.75).contains(&g.genes()[0]))
396            .map(|(_, w)| w)
397            .sum();
398        assert!(
399            ch_interior > 0.8,
400            "fixed-w Chebyshev interior mass {ch_interior:.3} — must reach the non-convex front interior"
401        );
402
403        // (c) Sweeping fixed weights traces the whole front, ends included.
404        for (w, lo, hi) in [(0.15, 0.75, 1.0), (0.5, 0.5, 0.75), (0.85, 0.1, 0.45)] {
405            let m = EvolutionModel::from_likelihood(
406                prior(),
407                ChebyshevScalarization::new(ConcaveFront, 25.0, vec![0.0, 0.0])
408                    .with_weight(vec![w, 1.0 - w]),
409            );
410            let r = EvolutionSMC::anneal(&mut rng, &m, cfg(), 8.0, 8);
411            let mean = r.weighted_mean(0);
412            assert!(
413                (lo..=hi).contains(&mean),
414                "weight {w}: front point {mean:.3} outside expected band [{lo}, {hi}]"
415            );
416        }
417    }
418
419    /// Latent-weight Chebyshev: the CONDITIONAL x | w tracks the front even
420    /// though the w-marginal is tilted (module-docs caveat). Among particles
421    /// whose latent weight is interior (w₀ ∈ [0.35, 0.65]), most mass must
422    /// sit in the interior of the front — the region a weighted sum's
423    /// conditional never occupies.
424    #[test]
425    fn test_chebyshev_latent_weight_conditional_tracks_front() {
426        #[derive(Clone)]
427        struct ConcaveFront;
428        impl MultiObjectiveFitness<RealVector> for ConcaveFront {
429            fn num_objectives(&self) -> usize {
430                2
431            }
432            fn evaluate(&self, g: &RealVector) -> Vec<f64> {
433                let x = g.genes()[0];
434                vec![x, 1.0 - x * x]
435            }
436        }
437
438        let prior = UniformBoxPrior::new(MultiBounds::new(vec![Bounds::new(0.0, 1.0)]));
439        let model = EvolutionModel::from_likelihood(
440            prior,
441            ChebyshevScalarization::new(ConcaveFront, 8.0, vec![0.0, 0.0]),
442        );
443        let mut rng = StdRng::seed_from_u64(314159);
444        // β = 1 posterior only — annealing would concentrate the tilted
445        // w-marginal onto the endpoints (see module docs).
446        let result = EvolutionSMC::run(
447            &mut rng,
448            &model,
449            EvoSmcConfig {
450                num_particles: 800,
451                ess_threshold: 0.5,
452                resampling: ResamplingMethod::Systematic,
453                rejuvenation_steps: 6,
454                crossover: Some(CrossoverConfig::default()),
455            },
456        );
457        let model_fn = model.smc_model();
458        let decoded = fugue::decode_particles(&result.particles, &model_fn);
459
460        let mut stratum_mass = 0.0;
461        let mut stratum_interior = 0.0;
462        for (p, (g, w)) in result.particles.iter().zip(&decoded) {
463            if let Some(wv) = particle_weights(&p.trace, 2) {
464                if (0.35..=0.65).contains(&wv[0]) {
465                    stratum_mass += w;
466                    let x = g.genes()[0];
467                    if (0.25..0.75).contains(&x) {
468                        stratum_interior += w;
469                    }
470                }
471            }
472        }
473        assert!(
474            stratum_mass > 0.02,
475            "interior-weight stratum carries only {stratum_mass:.4} mass — too depleted to test"
476        );
477        let frac = stratum_interior / stratum_mass;
478        assert!(
479            frac > 0.5,
480            "interior-weight particles put only {frac:.2} of their mass on the front interior"
481        );
482    }
483
484    /// Stick-breaking weights are a valid distribution over the simplex for
485    /// k = 3: components positive, summing to 1, with symmetric means.
486    #[test]
487    fn test_stick_breaking_weights_uniform_simplex() {
488        use fugue::runtime::handler::run;
489        use fugue::runtime::interpreters::PriorHandler;
490        let mut rng = StdRng::seed_from_u64(9);
491        let mut sums = [0.0f64; 3];
492        let n = 4000;
493        for _ in 0..n {
494            let (w, _) = run(
495                PriorHandler {
496                    rng: &mut rng,
497                    trace: Trace::default(),
498                },
499                weight_model(3),
500            );
501            assert!((w.iter().sum::<f64>() - 1.0).abs() < 1e-12);
502            assert!(w.iter().all(|&x| (0.0..=1.0).contains(&x)));
503            for (s, wi) in sums.iter_mut().zip(&w) {
504                *s += wi;
505            }
506        }
507        for s in sums {
508            let mean = s / n as f64;
509            assert!(
510                (mean - 1.0 / 3.0).abs() < 0.02,
511                "uniform-simplex component mean {mean} should be 1/3"
512            );
513        }
514    }
515}