Skip to main content

sklears_multioutput/optimization/
scalarization_methods.rs

1//! Scalarization Methods for Multi-Objective Optimization
2//!
3//! This module provides various scalarization techniques for converting multi-objective
4//! optimization problems into single-objective problems. These methods enable the systematic
5//! exploration of trade-offs between conflicting objectives.
6//!
7//! ## Key Features
8//!
9//! - **Weighted Sum Method**: Simple linear combination of objectives with user-defined weights
10//! - **Epsilon-Constraint Method**: Optimize one objective while constraining others
11//! - **Achievement Scalarizing Function**: Reference point-based optimization
12//! - **Augmented Weighted Tchebycheff**: Improved Tchebycheff scalarization
13//! - **Normalized Normal Constraint**: Advanced constraint handling for Pareto front generation
14//! - **Problem Generation**: Systematic generation of scalarized subproblems
15#![allow(non_snake_case)] // Standard ML notation: X for feature matrices, K for kernels
16
17// Use SciRS2-Core for arrays and random number generation (SciRS2 Policy)
18use scirs2_core::ndarray::{s, Array1, Array2, ArrayView2};
19use scirs2_core::random::thread_rng;
20use scirs2_core::random::RandNormal;
21use sklears_core::{
22    error::{Result as SklResult, SklearsError},
23    traits::{Estimator, Fit, Untrained},
24    types::Float,
25};
26
27/// Scalarization method types for multi-objective optimization
28#[derive(Debug, Clone, PartialEq)]
29pub enum ScalarizationMethod {
30    /// Weighted sum method with objective weights
31    WeightedSum(Vec<Float>),
32    /// Epsilon-constraint method with constraints and objective index
33    EpsilonConstraint {
34        objective_index: usize,
35        epsilon_values: Vec<Float>,
36    },
37    /// Achievement scalarizing function with reference point
38    AchievementScalarizingFunction {
39        reference_point: Vec<Float>,
40        augmentation_coefficient: Float,
41    },
42    /// Augmented weighted Tchebycheff method
43    AugmentedWeightedTchebycheff {
44        reference_point: Vec<Float>,
45        weights: Vec<Float>,
46        augmentation_coefficient: Float,
47    },
48    /// Normalized normal constraint method
49    NormalizedNormalConstraint {
50        anchor_points: Array2<Float>,
51        utopia_point: Vec<Float>,
52    },
53}
54
55/// Configuration for scalarization optimizer
56#[derive(Debug, Clone)]
57pub struct ScalarizationConfig {
58    /// Scalarization method to use
59    pub method: ScalarizationMethod,
60    /// Maximum number of iterations
61    pub max_iter: usize,
62    /// Convergence tolerance
63    pub tol: Float,
64    /// Learning rate for optimization
65    pub learning_rate: Float,
66    /// Random state for reproducibility
67    pub random_state: Option<u64>,
68}
69
70impl Default for ScalarizationConfig {
71    fn default() -> Self {
72        Self {
73            method: ScalarizationMethod::WeightedSum(vec![0.5, 0.5]),
74            max_iter: 1000,
75            tol: 1e-6,
76            learning_rate: 0.01,
77            random_state: None,
78        }
79    }
80}
81
82/// Scalarization optimizer for converting multi-objective problems to single-objective
83#[derive(Debug, Clone)]
84pub struct ScalarizationOptimizer<S = Untrained> {
85    state: S,
86    config: ScalarizationConfig,
87}
88
89/// Trained state for scalarization optimizer
90#[derive(Debug, Clone)]
91pub struct ScalarizationOptimizerTrained {
92    /// Model parameters
93    pub parameters: Array1<Float>,
94    /// Scalarized objective value
95    pub scalarized_value: Float,
96    /// Original objective values
97    pub objective_values: Vec<Float>,
98    /// Convergence history
99    pub convergence_history: Vec<Float>,
100    /// Number of features
101    pub n_features: usize,
102    /// Number of objectives
103    pub n_objectives: usize,
104    /// Configuration used
105    pub config: ScalarizationConfig,
106}
107
108impl ScalarizationOptimizer<Untrained> {
109    /// Create a new scalarization optimizer
110    pub fn new(method: ScalarizationMethod) -> Self {
111        Self {
112            state: Untrained,
113            config: ScalarizationConfig {
114                method,
115                ..Default::default()
116            },
117        }
118    }
119
120    /// Set the configuration
121    pub fn config(mut self, config: ScalarizationConfig) -> Self {
122        self.config = config;
123        self
124    }
125
126    /// Set maximum iterations
127    pub fn max_iter(mut self, max_iter: usize) -> Self {
128        self.config.max_iter = max_iter;
129        self
130    }
131
132    /// Set convergence tolerance
133    pub fn tol(mut self, tol: Float) -> Self {
134        self.config.tol = tol;
135        self
136    }
137
138    /// Set learning rate
139    pub fn learning_rate(mut self, learning_rate: Float) -> Self {
140        self.config.learning_rate = learning_rate;
141        self
142    }
143
144    /// Set random state
145    pub fn random_state(mut self, random_state: Option<u64>) -> Self {
146        self.config.random_state = random_state;
147        self
148    }
149
150    /// Compute scalarized objective value
151    pub fn scalarize_objectives(&self, objectives: &[Float]) -> SklResult<Float> {
152        match &self.config.method {
153            ScalarizationMethod::WeightedSum(weights) => {
154                if weights.len() != objectives.len() {
155                    return Err(SklearsError::InvalidInput(
156                        "Weight vector length must match number of objectives".to_string(),
157                    ));
158                }
159                Ok(objectives
160                    .iter()
161                    .zip(weights.iter())
162                    .map(|(obj, w)| obj * w)
163                    .sum())
164            }
165
166            ScalarizationMethod::EpsilonConstraint {
167                objective_index,
168                epsilon_values,
169            } => {
170                if *objective_index >= objectives.len() {
171                    return Err(SklearsError::InvalidInput(
172                        "Objective index out of bounds".to_string(),
173                    ));
174                }
175                if epsilon_values.len() != objectives.len() - 1 {
176                    return Err(SklearsError::InvalidInput(
177                        "Epsilon values length must be objectives - 1".to_string(),
178                    ));
179                }
180
181                // Check epsilon constraints
182                let mut eps_idx = 0;
183                for (i, &obj_val) in objectives.iter().enumerate() {
184                    if i != *objective_index {
185                        if obj_val > epsilon_values[eps_idx] {
186                            return Ok(Float::INFINITY); // Constraint violated
187                        }
188                        eps_idx += 1;
189                    }
190                }
191
192                Ok(objectives[*objective_index])
193            }
194
195            ScalarizationMethod::AchievementScalarizingFunction {
196                reference_point,
197                augmentation_coefficient,
198            } => {
199                if reference_point.len() != objectives.len() {
200                    return Err(SklearsError::InvalidInput(
201                        "Reference point length must match number of objectives".to_string(),
202                    ));
203                }
204
205                let max_normalized_diff = objectives
206                    .iter()
207                    .zip(reference_point.iter())
208                    .map(|(obj, ref_pt)| (obj - ref_pt).max(0.0))
209                    .fold(0.0, Float::max);
210
211                let augmentation_term = augmentation_coefficient
212                    * objectives
213                        .iter()
214                        .zip(reference_point.iter())
215                        .map(|(obj, ref_pt)| obj - ref_pt)
216                        .sum::<Float>();
217
218                Ok(max_normalized_diff + augmentation_term)
219            }
220
221            ScalarizationMethod::AugmentedWeightedTchebycheff {
222                reference_point,
223                weights,
224                augmentation_coefficient,
225            } => {
226                if reference_point.len() != objectives.len() || weights.len() != objectives.len() {
227                    return Err(SklearsError::InvalidInput(
228                        "Reference point and weights length must match number of objectives"
229                            .to_string(),
230                    ));
231                }
232
233                let max_weighted_diff = objectives
234                    .iter()
235                    .zip(reference_point.iter())
236                    .zip(weights.iter())
237                    .map(|((obj, ref_pt), w)| w * (obj - ref_pt).abs())
238                    .fold(0.0, Float::max);
239
240                let augmentation_term = augmentation_coefficient
241                    * objectives
242                        .iter()
243                        .zip(reference_point.iter())
244                        .map(|(obj, ref_pt)| obj - ref_pt)
245                        .sum::<Float>();
246
247                Ok(max_weighted_diff + augmentation_term)
248            }
249
250            ScalarizationMethod::NormalizedNormalConstraint {
251                anchor_points,
252                utopia_point,
253            } => {
254                if utopia_point.len() != objectives.len() {
255                    return Err(SklearsError::InvalidInput(
256                        "Utopia point length must match number of objectives".to_string(),
257                    ));
258                }
259
260                // Normalize objectives
261                let normalized_objectives: Vec<Float> = objectives
262                    .iter()
263                    .zip(utopia_point.iter())
264                    .enumerate()
265                    .map(|(i, (obj, utopia))| {
266                        let anchor = anchor_points[[i, i]];
267                        if (anchor - utopia).abs() > 1e-10 {
268                            (obj - utopia) / (anchor - utopia)
269                        } else {
270                            0.0
271                        }
272                    })
273                    .collect();
274
275                // Compute Euclidean distance from origin
276                Ok(normalized_objectives
277                    .iter()
278                    .map(|x| x * x)
279                    .sum::<Float>()
280                    .sqrt())
281            }
282        }
283    }
284
285    /// Generate multiple scalarized problems for comprehensive optimization
286    pub fn generate_scalarized_problems(
287        &self,
288        n_problems: usize,
289        n_objectives: usize,
290    ) -> SklResult<Vec<ScalarizationMethod>> {
291        let mut problems = Vec::new();
292
293        match &self.config.method {
294            ScalarizationMethod::WeightedSum(_) => {
295                // Generate uniformly distributed weight vectors
296                for i in 0..n_problems {
297                    let mut weights = vec![0.0; n_objectives];
298                    let step = 1.0 / (n_problems - 1) as Float;
299                    weights[0] = i as Float * step;
300                    weights[1] = 1.0 - weights[0];
301
302                    // Extend to higher dimensions using Dirichlet-like distribution
303                    if n_objectives > 2 {
304                        let remaining = weights[1];
305                        weights[1] = remaining * (i as Float / n_problems as Float);
306                        for weight in weights[2..].iter_mut() {
307                            *weight = remaining / (n_objectives - 1) as Float;
308                        }
309                    }
310
311                    problems.push(ScalarizationMethod::WeightedSum(weights));
312                }
313            }
314
315            ScalarizationMethod::EpsilonConstraint {
316                objective_index, ..
317            } => {
318                // Generate different epsilon values
319                for i in 0..n_problems {
320                    let step = 1.0 / n_problems as Float;
321                    let epsilon_values = (0..n_objectives - 1)
322                        .map(|_| (i as Float + 1.0) * step)
323                        .collect();
324
325                    problems.push(ScalarizationMethod::EpsilonConstraint {
326                        objective_index: *objective_index,
327                        epsilon_values,
328                    });
329                }
330            }
331
332            _ => {
333                // For other methods, generate variations of the current method
334                for _ in 0..n_problems {
335                    problems.push(self.config.method.clone());
336                }
337            }
338        }
339
340        Ok(problems)
341    }
342}
343
344impl Fit<ArrayView2<'_, Float>, ArrayView2<'_, Float>> for ScalarizationOptimizer<Untrained> {
345    type Fitted = ScalarizationOptimizer<ScalarizationOptimizerTrained>;
346
347    fn fit(self, X: &ArrayView2<'_, Float>, y: &ArrayView2<'_, Float>) -> SklResult<Self::Fitted> {
348        let (n_samples, n_features) = X.dim();
349        let (y_samples, n_objectives) = y.dim();
350
351        if n_samples != y_samples {
352            return Err(SklearsError::InvalidInput(
353                "X and y must have the same number of samples".to_string(),
354            ));
355        }
356
357        let mut rng = thread_rng();
358
359        // Initialize parameters
360        let normal_dist = RandNormal::new(0.0, 0.1).expect("operation should succeed");
361        let mut parameters = Array1::<Float>::zeros(n_features * n_objectives);
362        for i in 0..(n_features * n_objectives) {
363            parameters[i] = rng.sample(normal_dist);
364        }
365
366        let mut convergence_history = Vec::new();
367        let mut prev_scalarized_value = Float::INFINITY;
368
369        for _iteration in 0..self.config.max_iter {
370            // Compute current objectives (simplified for demonstration)
371            let objectives: Vec<Float> = (0..n_objectives)
372                .map(|i| {
373                    let param_slice = parameters.slice(s![i * n_features..(i + 1) * n_features]);
374                    // Simplified objective computation - in practice this would be problem-specific
375                    param_slice.iter().map(|x| x * x).sum::<Float>() / n_features as Float
376                })
377                .collect();
378
379            // Scalarize objectives
380            let scalarized_value = self.scalarize_objectives(&objectives)?;
381            convergence_history.push(scalarized_value);
382
383            // Check convergence
384            if (prev_scalarized_value - scalarized_value).abs() < self.config.tol {
385                break;
386            }
387            prev_scalarized_value = scalarized_value;
388
389            // Simplified gradient descent update
390            for i in 0..parameters.len() {
391                let gradient = 2.0 * parameters[i] / n_features as Float; // Simplified gradient
392                parameters[i] -= self.config.learning_rate * gradient;
393            }
394        }
395
396        // Final objective computation
397        let final_objectives: Vec<Float> = (0..n_objectives)
398            .map(|i| {
399                let param_slice = parameters.slice(s![i * n_features..(i + 1) * n_features]);
400                param_slice.iter().map(|x| x * x).sum::<Float>() / n_features as Float
401            })
402            .collect();
403
404        let final_scalarized_value = self.scalarize_objectives(&final_objectives)?;
405
406        Ok(ScalarizationOptimizer {
407            state: ScalarizationOptimizerTrained {
408                parameters,
409                scalarized_value: final_scalarized_value,
410                objective_values: final_objectives,
411                convergence_history,
412                n_features,
413                n_objectives,
414                config: self.config.clone(),
415            },
416            config: self.config,
417        })
418    }
419}
420
421impl ScalarizationOptimizer<ScalarizationOptimizerTrained> {
422    /// Get the optimized parameters
423    pub fn parameters(&self) -> &Array1<Float> {
424        &self.state.parameters
425    }
426
427    /// Get the scalarized objective value
428    pub fn scalarized_value(&self) -> Float {
429        self.state.scalarized_value
430    }
431
432    /// Get the original objective values
433    pub fn objective_values(&self) -> &[Float] {
434        &self.state.objective_values
435    }
436
437    /// Get the convergence history
438    pub fn convergence_history(&self) -> &[Float] {
439        &self.state.convergence_history
440    }
441
442    /// Get the scalarization method used
443    pub fn method(&self) -> &ScalarizationMethod {
444        &self.state.config.method
445    }
446
447    /// Check if the solution is feasible (for constraint-based methods)
448    pub fn is_feasible(&self) -> bool {
449        match &self.state.config.method {
450            ScalarizationMethod::EpsilonConstraint {
451                epsilon_values,
452                objective_index,
453            } => {
454                let mut eps_idx = 0;
455                for (i, &obj_val) in self.state.objective_values.iter().enumerate() {
456                    if i != *objective_index {
457                        if obj_val > epsilon_values[eps_idx] {
458                            return false;
459                        }
460                        eps_idx += 1;
461                    }
462                }
463                true
464            }
465            _ => true, // Other methods don't have hard constraints
466        }
467    }
468}
469
470impl Estimator for ScalarizationOptimizer<Untrained> {
471    type Config = ScalarizationConfig;
472    type Error = SklearsError;
473    type Float = Float;
474
475    fn config(&self) -> &Self::Config {
476        &self.config
477    }
478}
479
480impl Estimator for ScalarizationOptimizer<ScalarizationOptimizerTrained> {
481    type Config = ScalarizationConfig;
482    type Error = SklearsError;
483    type Float = Float;
484
485    fn config(&self) -> &Self::Config {
486        &self.state.config
487    }
488}