Skip to main content

optirs_nas/multi_objective/
weighted_sum.rs

1//! Weighted-sum scalarization multi-objective optimizer.
2
3use crate::error::{OptimError, Result};
4use crate::nas_engine::{
5    MultiObjectiveConfig, ObjectiveConfig, ObjectiveType, OptimizationDirection,
6    OptimizerArchitecture, SearchResult,
7};
8use crate::EvaluationMetric;
9use scirs2_core::numeric::Float;
10use std::cmp::Ordering;
11use std::fmt::Debug;
12
13use super::core::{
14    CreationMethod, Individual, MultiObjectiveOptimizer, MultiObjectiveStatistics, ObjectiveBounds,
15    ParetoFront, ParetoSolution, SolutionMetadata,
16};
17
18/// Weighted sum approach
19pub struct WeightedSum<T: Float + Debug + Send + Sync + 'static> {
20    /// Objective weights
21    pub(super) weights: Vec<T>,
22    /// Objective configurations (direction, type, ...) used for
23    /// scalarization and dominance computations.
24    pub(super) objectives: Vec<ObjectiveConfig<T>>,
25    /// Current best solution
26    pub(super) best_solution: Option<Individual<T>>,
27    /// Statistics
28    pub(super) statistics: MultiObjectiveStatistics<T>,
29    /// Maintained non-dominated set (Pareto front)
30    pub(super) pareto_front: ParetoFront<T>,
31    /// Generation counter
32    pub(super) generation: usize,
33}
34impl<T: Float + Debug + Default + Clone + Send + Sync + 'static> WeightedSum<T> {
35    pub fn new(objectives: &[ObjectiveConfig<T>]) -> Result<Self> {
36        let weights = objectives.iter().map(|obj| obj.weight).collect();
37        Ok(Self {
38            weights,
39            objectives: objectives.to_vec(),
40            best_solution: None,
41            statistics: MultiObjectiveStatistics::default(),
42            pareto_front: ParetoFront::default(),
43            generation: 0,
44        })
45    }
46    /// Return the best scalarized solution observed by the most recent
47    /// [`select_candidates`](MultiObjectiveOptimizer::select_candidates) call,
48    /// if any.
49    pub fn best_solution(&self) -> Option<&Individual<T>> {
50        self.best_solution.as_ref()
51    }
52    /// Extract the objective vector for a search result, mapping each
53    /// configured [`ObjectiveType`] onto the corresponding
54    /// [`EvaluationMetric`]. Mirrors the mapping used by the NSGA-II
55    /// implementation so that the two optimizers agree on objective values.
56    pub(super) fn extract_objectives(&self, result: &SearchResult<T>) -> Vec<T> {
57        let mut objectives = Vec::with_capacity(self.objectives.len());
58        for obj_config in &self.objectives {
59            let metric = match obj_config.objective_type {
60                ObjectiveType::Accuracy => EvaluationMetric::Accuracy,
61                ObjectiveType::Loss => EvaluationMetric::FinalPerformance,
62                ObjectiveType::TrainingTime => EvaluationMetric::TrainingTime,
63                ObjectiveType::InferenceTime => EvaluationMetric::ComputationTime,
64                ObjectiveType::MemoryUsage => EvaluationMetric::MemoryUsage,
65                ObjectiveType::EnergyConsumption => EvaluationMetric::ComputationTime,
66                ObjectiveType::ModelSize => EvaluationMetric::MemoryUsage,
67                ObjectiveType::Performance => EvaluationMetric::FinalPerformance,
68                ObjectiveType::Efficiency => EvaluationMetric::ComputationalEfficiency,
69                ObjectiveType::Robustness => EvaluationMetric::Robustness,
70                ObjectiveType::Interpretability => EvaluationMetric::FinalPerformance,
71                ObjectiveType::Fairness => EvaluationMetric::FinalPerformance,
72                ObjectiveType::Privacy => EvaluationMetric::FinalPerformance,
73                ObjectiveType::Sustainability => EvaluationMetric::ComputationalEfficiency,
74                ObjectiveType::Cost => EvaluationMetric::ComputationalEfficiency,
75                ObjectiveType::Custom(_) => EvaluationMetric::FinalPerformance,
76            };
77            let value = result
78                .evaluation_results
79                .metric_scores
80                .get(&metric)
81                .cloned()
82                .unwrap_or(T::zero());
83            objectives.push(value);
84        }
85        objectives
86    }
87    /// Weighted-sum scalarized **cost** of a single search result, using the
88    /// configured (normalized) weights and honoring each objective's direction so
89    /// that lower is always better.
90    pub fn scalarized_cost_for_result(&self, result: &SearchResult<T>) -> T {
91        let objectives = self.extract_objectives(result);
92        self.scalarize(&objectives, &self.normalized_weights())
93    }
94    /// Mean pairwise Euclidean distance between the objective vectors of
95    /// `results` — a real diversity measure over the population.
96    pub fn mean_objective_distance(&self, results: &[SearchResult<T>]) -> f64 {
97        if results.len() < 2 {
98            return 0.0;
99        }
100        let vectors: Vec<Vec<T>> = results
101            .iter()
102            .map(|result| self.extract_objectives(result))
103            .collect();
104        let mut total = 0.0;
105        let mut count = 0usize;
106        for i in 0..vectors.len() {
107            for j in (i + 1)..vectors.len() {
108                let a = &vectors[i];
109                let b = &vectors[j];
110                let mut sq_sum = T::zero();
111                for d in 0..a.len().min(b.len()) {
112                    let diff = a[d] - b[d];
113                    sq_sum = sq_sum + diff * diff;
114                }
115                total += sq_sum.sqrt().to_f64().unwrap_or(0.0);
116                count += 1;
117            }
118        }
119        if count > 0 {
120            total / count as f64
121        } else {
122            0.0
123        }
124    }
125    /// Determine whether objective vector `a` Pareto-dominates `b`.
126    ///
127    /// Uses the same convention as NSGA-II's `dominance_relation`: `a`
128    /// dominates `b` when `a` improves at least one objective and worsens
129    /// none, honoring each objective's optimization direction.
130    pub(super) fn dominates(&self, a: &[T], b: &[T]) -> bool {
131        let mut a_strictly_better = false;
132        for (k, obj_config) in self.objectives.iter().enumerate() {
133            if k >= a.len() || k >= b.len() {
134                break;
135            }
136            let val_a = a[k];
137            let val_b = b[k];
138            match obj_config.direction {
139                OptimizationDirection::Minimize => {
140                    if val_a > val_b {
141                        return false;
142                    } else if val_a < val_b {
143                        a_strictly_better = true;
144                    }
145                }
146                OptimizationDirection::Maximize => {
147                    if val_a < val_b {
148                        return false;
149                    } else if val_a > val_b {
150                        a_strictly_better = true;
151                    }
152                }
153            }
154        }
155        a_strictly_better
156    }
157    /// Compute the weighted-sum scalarization (as a cost to be **minimized**)
158    /// for a single objective vector. Maximize objectives are negated so that
159    /// lower scalarized values are always preferred.
160    pub(super) fn scalarize(&self, objectives: &[T], normalized_weights: &[T]) -> T {
161        let mut score = T::zero();
162        for (k, &weight) in normalized_weights.iter().enumerate() {
163            if k >= objectives.len() {
164                break;
165            }
166            let directed = match self.objectives.get(k).map(|c| &c.direction) {
167                Some(OptimizationDirection::Maximize) => -objectives[k],
168                _ => objectives[k],
169            };
170            score = score + weight * directed;
171        }
172        score
173    }
174    /// Return weights normalized to sum to one. Falls back to uniform weights
175    /// when the configured weights are degenerate (empty or non-positive sum).
176    pub(super) fn normalized_weights(&self) -> Vec<T> {
177        let n = self.weights.len();
178        if n == 0 {
179            return Vec::new();
180        }
181        let mut sum = T::zero();
182        for &w in &self.weights {
183            if w > T::zero() {
184                sum = sum + w;
185            }
186        }
187        if sum > T::zero() {
188            self.weights
189                .iter()
190                .map(|&w| if w > T::zero() { w / sum } else { T::zero() })
191                .collect()
192        } else {
193            let uniform = T::one() / T::from(n).unwrap_or_else(T::one);
194            vec![uniform; n]
195        }
196    }
197    /// Rebuild the objective-space bounds and basic front metrics for the
198    /// maintained Pareto front. Mirrors NSGA-II's bookkeeping so downstream
199    /// consumers observe a consistently-populated [`ParetoFront`].
200    pub(super) fn refresh_front_metrics(&mut self) {
201        if self.pareto_front.solutions.is_empty() {
202            self.pareto_front.objective_bounds = ObjectiveBounds {
203                min_values: Vec::new(),
204                max_values: Vec::new(),
205                ideal_point: Vec::new(),
206                nadir_point: Vec::new(),
207            };
208            self.pareto_front.metrics.num_solutions = 0;
209            self.statistics.pareto_front_size = 0;
210            return;
211        }
212        let num_objectives = self.pareto_front.solutions[0].objectives.len();
213        let mut min_values = vec![T::infinity(); num_objectives];
214        let mut max_values = vec![T::neg_infinity(); num_objectives];
215        for solution in &self.pareto_front.solutions {
216            for (i, &obj_val) in solution.objectives.iter().enumerate() {
217                if i >= num_objectives {
218                    break;
219                }
220                if obj_val < min_values[i] {
221                    min_values[i] = obj_val;
222                }
223                if obj_val > max_values[i] {
224                    max_values[i] = obj_val;
225                }
226            }
227        }
228        self.pareto_front.objective_bounds = ObjectiveBounds {
229            min_values: min_values.clone(),
230            max_values: max_values.clone(),
231            ideal_point: min_values,
232            nadir_point: max_values,
233        };
234        let count = self.pareto_front.solutions.len();
235        self.pareto_front.metrics.num_solutions = count;
236        self.statistics.pareto_front_size = count;
237    }
238}
239impl<
240        T: Float
241            + Debug
242            + Default
243            + Clone
244            + Send
245            + Sync
246            + std::fmt::Debug
247            + PartialOrd
248            + std::iter::Sum,
249    > MultiObjectiveOptimizer<T> for WeightedSum<T>
250{
251    /// Adopt `config` as the live objective set and reset the per-run state.
252    ///
253    /// The body used to be a bare `Ok(())`, so a caller that constructed the
254    /// scalarizer from one objective list and then handed `initialize` a different
255    /// [`MultiObjectiveConfig`] kept scalarizing against the constructor's
256    /// objectives — the configuration it was given was silently discarded. Every
257    /// sibling optimizer ([`super::NSGA2`], [`super::NSGA3`],
258    /// [`super::MOEADOptimizer`]) absorbs the configuration here; this one now does
259    /// too, and clears the front/statistics so a re-initialized optimizer never
260    /// reports a front that was built under the previous objectives.
261    fn initialize(&mut self, config: &MultiObjectiveConfig<T>) -> Result<()> {
262        if config.objectives.is_empty() {
263            return Err(OptimError::InvalidConfig(
264                "WeightedSum has nothing to scalarize: the supplied \
265                 MultiObjectiveConfig declares no objectives"
266                    .to_string(),
267            ));
268        }
269        self.weights = config.objectives.iter().map(|obj| obj.weight).collect();
270        self.objectives = config.objectives.clone();
271        self.best_solution = None;
272        self.generation = 0;
273        self.statistics = MultiObjectiveStatistics::default();
274        self.pareto_front = ParetoFront::default();
275        Ok(())
276    }
277    fn update_pareto_front(&mut self, new_solutions: &[SearchResult<T>]) -> Result<ParetoFront<T>> {
278        if new_solutions.is_empty() {
279            return Ok(self.pareto_front.clone());
280        }
281        self.generation += 1;
282        self.statistics.total_evaluations += new_solutions.len();
283        let mut candidates: Vec<(Vec<T>, ParetoSolution<T>)> =
284            Vec::with_capacity(self.pareto_front.solutions.len() + new_solutions.len());
285        for solution in &self.pareto_front.solutions {
286            candidates.push((solution.objectives.clone(), solution.clone()));
287        }
288        for result in new_solutions {
289            let objectives = self.extract_objectives(result);
290            let solution = ParetoSolution {
291                architecture: result.architecture.clone(),
292                objectives: objectives.clone(),
293                constraint_violations: Vec::new(),
294                rank: 0,
295                crowding_distance: T::zero(),
296                metadata: SolutionMetadata {
297                    id: result.architecture.architecture_id.clone(),
298                    generation: self.generation,
299                    evaluation_count: self.statistics.total_evaluations,
300                    parents: Vec::new(),
301                    creation_method: CreationMethod::RandomGeneration,
302                },
303            };
304            candidates.push((objectives, solution));
305        }
306        let mut front: Vec<ParetoSolution<T>> = Vec::new();
307        for i in 0..candidates.len() {
308            let mut dominated = false;
309            for j in 0..candidates.len() {
310                if i != j && self.dominates(&candidates[j].0, &candidates[i].0) {
311                    dominated = true;
312                    break;
313                }
314            }
315            if dominated {
316                continue;
317            }
318            let is_duplicate = front
319                .iter()
320                .any(|existing| existing.objectives == candidates[i].0);
321            if !is_duplicate {
322                front.push(candidates[i].1.clone());
323            }
324        }
325        self.pareto_front.solutions = front;
326        self.pareto_front.generation = self.generation;
327        self.pareto_front.last_updated = std::time::SystemTime::now();
328        self.refresh_front_metrics();
329        Ok(self.pareto_front.clone())
330    }
331    fn get_pareto_front(&self) -> &ParetoFront<T> {
332        &self.pareto_front
333    }
334    fn select_candidates(
335        &mut self,
336        population: &[OptimizerArchitecture<T>],
337        objectives: &[T],
338    ) -> Result<Vec<OptimizerArchitecture<T>>> {
339        if population.is_empty() {
340            return Ok(Vec::new());
341        }
342        let num_objectives = self.objectives.len();
343        let normalized_weights = self.normalized_weights();
344        let mut scored: Vec<(usize, T)> = Vec::with_capacity(population.len());
345        for (idx, _architecture) in population.iter().enumerate() {
346            let obj_vector: Vec<T> = if num_objectives > 0 {
347                (0..num_objectives)
348                    .map(|k| {
349                        let flat = idx * num_objectives + k;
350                        objectives.get(flat).cloned().unwrap_or(T::zero())
351                    })
352                    .collect()
353            } else {
354                Vec::new()
355            };
356            let score = self.scalarize(&obj_vector, &normalized_weights);
357            scored.push((idx, score));
358        }
359        scored.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal));
360        if let Some(&(best_idx, best_score)) = scored.first() {
361            let best_objectives: Vec<T> = if num_objectives > 0 {
362                (0..num_objectives)
363                    .map(|k| {
364                        let flat = best_idx * num_objectives + k;
365                        objectives.get(flat).cloned().unwrap_or(T::zero())
366                    })
367                    .collect()
368            } else {
369                Vec::new()
370            };
371            self.best_solution = Some(Individual {
372                architecture: population[best_idx].clone(),
373                objectives: best_objectives,
374                constraints: Vec::new(),
375                rank: 0,
376                crowding_distance: T::zero(),
377                fitness: best_score,
378                id: population[best_idx].architecture_id.clone(),
379            });
380        }
381        let selection_count = (population.len() / 2).max(1).min(population.len());
382        let selected = scored
383            .iter()
384            .take(selection_count)
385            .map(|&(idx, _)| population[idx].clone())
386            .collect();
387        Ok(selected)
388    }
389    fn name(&self) -> &str {
390        "WeightedSum"
391    }
392    fn get_statistics(&self) -> MultiObjectiveStatistics<T> {
393        self.statistics.clone()
394    }
395}