Skip to main content

optimizer/study/
analysis.rs

1use crate::sampler::CompletedTrial;
2use crate::types::TrialState;
3
4use super::Study;
5
6impl<V> Study<V>
7where
8    V: PartialOrd,
9{
10    /// Return the trial with the best objective value.
11    ///
12    /// The "best" trial depends on the optimization direction:
13    /// - `Direction::Minimize`: Returns the trial with the lowest objective value.
14    /// - `Direction::Maximize`: Returns the trial with the highest objective value.
15    ///
16    /// When constraints are present, feasible trials always rank above infeasible
17    /// trials. Among infeasible trials, those with lower total constraint violation
18    /// are preferred.
19    ///
20    /// # Errors
21    ///
22    /// Returns `Error::NoCompletedTrials` if no trials have been completed.
23    ///
24    /// # Examples
25    ///
26    /// ```
27    /// use optimizer::parameter::{FloatParam, Parameter};
28    /// use optimizer::{Direction, Study};
29    ///
30    /// let study: Study<f64> = Study::new(Direction::Minimize);
31    ///
32    /// // Error when no trials completed
33    /// assert!(study.best_trial().is_err());
34    ///
35    /// let x_param = FloatParam::new(0.0, 1.0);
36    ///
37    /// let mut trial1 = study.create_trial();
38    /// let _ = x_param.suggest(&mut trial1);
39    /// study.complete_trial(trial1, 0.8);
40    ///
41    /// let mut trial2 = study.create_trial();
42    /// let _ = x_param.suggest(&mut trial2);
43    /// study.complete_trial(trial2, 0.3);
44    ///
45    /// let best = study.best_trial().unwrap();
46    /// assert_eq!(best.value, 0.3); // Minimize: lower is better
47    /// ```
48    pub fn best_trial(&self) -> crate::Result<CompletedTrial<V>>
49    where
50        V: Clone,
51    {
52        let trials = self.storage.trials_arc().read();
53        let direction = self.direction;
54
55        let best = trials
56            .iter()
57            .filter(|t| t.state == TrialState::Complete)
58            .max_by(|a, b| Self::compare_trials(a, b, direction))
59            .ok_or(crate::Error::NoCompletedTrials)?;
60
61        Ok(best.clone())
62    }
63
64    /// Return the best objective value found so far.
65    ///
66    /// The "best" value depends on the optimization direction:
67    /// - `Direction::Minimize`: Returns the lowest objective value.
68    /// - `Direction::Maximize`: Returns the highest objective value.
69    ///
70    /// # Errors
71    ///
72    /// Returns `Error::NoCompletedTrials` if no trials have been completed.
73    ///
74    /// # Examples
75    ///
76    /// ```
77    /// use optimizer::parameter::{FloatParam, Parameter};
78    /// use optimizer::{Direction, Study};
79    ///
80    /// let study: Study<f64> = Study::new(Direction::Maximize);
81    ///
82    /// // Error when no trials completed
83    /// assert!(study.best_value().is_err());
84    ///
85    /// let x_param = FloatParam::new(0.0, 1.0);
86    ///
87    /// let mut trial1 = study.create_trial();
88    /// let _ = x_param.suggest(&mut trial1);
89    /// study.complete_trial(trial1, 0.3);
90    ///
91    /// let mut trial2 = study.create_trial();
92    /// let _ = x_param.suggest(&mut trial2);
93    /// study.complete_trial(trial2, 0.8);
94    ///
95    /// let best = study.best_value().unwrap();
96    /// assert_eq!(best, 0.8); // Maximize: higher is better
97    /// ```
98    pub fn best_value(&self) -> crate::Result<V>
99    where
100        V: Clone,
101    {
102        self.best_trial().map(|trial| trial.value)
103    }
104
105    /// Return the top `n` trials sorted by objective value.
106    ///
107    /// For `Direction::Minimize`, returns trials with the lowest values.
108    /// For `Direction::Maximize`, returns trials with the highest values.
109    /// Only includes completed trials (not failed or pruned).
110    ///
111    /// If fewer than `n` completed trials exist, returns all of them.
112    ///
113    /// # Examples
114    ///
115    /// ```
116    /// use optimizer::parameter::{FloatParam, Parameter};
117    /// use optimizer::{Direction, Study};
118    ///
119    /// let study: Study<f64> = Study::new(Direction::Minimize);
120    /// let x = FloatParam::new(0.0, 10.0);
121    ///
122    /// for val in [5.0, 1.0, 3.0] {
123    ///     let mut t = study.create_trial();
124    ///     let _ = x.suggest(&mut t);
125    ///     study.complete_trial(t, val);
126    /// }
127    ///
128    /// let top2 = study.top_trials(2);
129    /// assert_eq!(top2.len(), 2);
130    /// assert!(top2[0].value <= top2[1].value);
131    /// ```
132    #[must_use]
133    pub fn top_trials(&self, n: usize) -> Vec<CompletedTrial<V>>
134    where
135        V: Clone,
136    {
137        let trials = self.storage.trials_arc().read();
138        let direction = self.direction;
139        // Sort indices instead of cloning all trials, then clone only the top N.
140        let mut indices: Vec<usize> = trials
141            .iter()
142            .enumerate()
143            .filter(|(_, t)| t.state == TrialState::Complete)
144            .map(|(i, _)| i)
145            .collect();
146        // Sort best-first: reverse the compare_trials ordering (which is designed for max_by)
147        indices.sort_by(|&a, &b| Self::compare_trials(&trials[b], &trials[a], direction));
148        indices.truncate(n);
149        indices.iter().map(|&i| trials[i].clone()).collect()
150    }
151}
152
153impl<V> Study<V>
154where
155    V: PartialOrd + Clone + Into<f64>,
156{
157    /// Compute parameter importance scores using Spearman rank correlation.
158    ///
159    /// For each parameter, the absolute Spearman correlation between its values
160    /// and the objective values is computed across all completed trials. Scores
161    /// are normalized so they sum to 1.0 and sorted in descending order.
162    ///
163    /// Parameters that appear in fewer than 2 trials are omitted.
164    /// Returns an empty `Vec` if the study has fewer than 2 completed trials.
165    ///
166    /// # Examples
167    ///
168    /// ```
169    /// use optimizer::parameter::{FloatParam, Parameter};
170    /// use optimizer::{Direction, Study};
171    ///
172    /// let study: Study<f64> = Study::new(Direction::Minimize);
173    /// let x = FloatParam::new(0.0, 10.0).name("x");
174    ///
175    /// study
176    ///     .optimize(20, |trial: &mut optimizer::Trial| {
177    ///         let xv = x.suggest(trial)?;
178    ///         Ok::<_, optimizer::Error>(xv * xv)
179    ///     })
180    ///     .unwrap();
181    ///
182    /// let importance = study.param_importance();
183    /// assert_eq!(importance.len(), 1);
184    /// assert_eq!(importance[0].0, "x");
185    /// ```
186    #[must_use]
187    #[allow(clippy::cast_precision_loss)]
188    pub fn param_importance(&self) -> Vec<(String, f64)> {
189        use std::collections::BTreeSet;
190
191        use crate::importance::spearman;
192        use crate::param::ParamValue;
193        use crate::types::TrialState;
194
195        let trials = self.storage.trials_arc().read();
196        let complete: Vec<_> = trials
197            .iter()
198            .filter(|t| t.state == TrialState::Complete)
199            .collect();
200
201        if complete.len() < 2 {
202            return Vec::new();
203        }
204
205        // Collect all parameter IDs across trials.
206        let all_param_ids: BTreeSet<_> = complete.iter().flat_map(|t| t.params.keys()).collect();
207
208        let mut scores: Vec<(String, f64)> = Vec::with_capacity(all_param_ids.len());
209
210        for &param_id in &all_param_ids {
211            // Collect (param_value_f64, objective_f64) for trials that have this param.
212            let mut param_vals = Vec::with_capacity(complete.len());
213            let mut obj_vals = Vec::with_capacity(complete.len());
214
215            for trial in &complete {
216                if let Some(pv) = trial.params.get(param_id) {
217                    let f = match *pv {
218                        ParamValue::Float(v) => v,
219                        ParamValue::Int(v) => v as f64,
220                        ParamValue::Categorical(v) => v as f64,
221                    };
222                    param_vals.push(f);
223                    obj_vals.push(trial.value.clone().into());
224                }
225            }
226
227            if param_vals.len() < 2 {
228                continue;
229            }
230
231            let corr = spearman(&param_vals, &obj_vals).abs();
232
233            // Determine label: use param_labels if available, else "param_{id}".
234            let label = complete
235                .iter()
236                .find_map(|t| t.param_labels.get(param_id))
237                .map_or_else(|| param_id.to_string(), Clone::clone);
238
239            scores.push((label, corr));
240        }
241
242        // Normalize so scores sum to 1.0.
243        let sum: f64 = scores.iter().map(|(_, s)| *s).sum();
244        if sum > 0.0 {
245            for entry in &mut scores {
246                entry.1 /= sum;
247            }
248        }
249
250        // Sort descending by score.
251        scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(core::cmp::Ordering::Equal));
252
253        scores
254    }
255
256    /// Compute parameter importance using fANOVA (functional ANOVA) with
257    /// default configuration.
258    ///
259    /// Fits a random forest to the trial data and decomposes variance into
260    /// per-parameter main effects and pairwise interaction effects. This is
261    /// more accurate than correlation-based importance ([`Self::param_importance`])
262    /// and can detect non-linear relationships and parameter interactions.
263    ///
264    /// # Errors
265    ///
266    /// Returns [`crate::Error::NoCompletedTrials`] if fewer than 2 trials have completed.
267    ///
268    /// # Examples
269    ///
270    /// ```
271    /// use optimizer::parameter::{FloatParam, Parameter};
272    /// use optimizer::{Direction, Study};
273    ///
274    /// let study: Study<f64> = Study::new(Direction::Minimize);
275    /// let x = FloatParam::new(0.0, 10.0).name("x");
276    /// let y = FloatParam::new(0.0, 10.0).name("y");
277    ///
278    /// study
279    ///     .optimize(30, |trial: &mut optimizer::Trial| {
280    ///         let xv = x.suggest(trial)?;
281    ///         let yv = y.suggest(trial)?;
282    ///         Ok::<_, optimizer::Error>(xv * xv + 0.1 * yv)
283    ///     })
284    ///     .unwrap();
285    ///
286    /// let result = study.fanova().unwrap();
287    /// assert!(!result.main_effects.is_empty());
288    /// ```
289    pub fn fanova(&self) -> crate::Result<crate::fanova::FanovaResult> {
290        self.fanova_with_config(&crate::fanova::FanovaConfig::default())
291    }
292
293    /// Compute parameter importance using fANOVA with custom configuration.
294    ///
295    /// See [`Self::fanova`] for details. The [`FanovaConfig`](crate::fanova::FanovaConfig)
296    /// allows tuning the number of trees, tree depth, and random seed.
297    ///
298    /// # Errors
299    ///
300    /// Returns [`crate::Error::NoCompletedTrials`] if fewer than 2 trials have completed.
301    #[allow(clippy::cast_precision_loss)]
302    pub fn fanova_with_config(
303        &self,
304        config: &crate::fanova::FanovaConfig,
305    ) -> crate::Result<crate::fanova::FanovaResult> {
306        use std::collections::BTreeSet;
307
308        use crate::fanova::compute_fanova;
309        use crate::param::ParamValue;
310        use crate::types::TrialState;
311
312        let trials = self.storage.trials_arc().read();
313        let complete: Vec<_> = trials
314            .iter()
315            .filter(|t| t.state == TrialState::Complete)
316            .collect();
317
318        if complete.len() < 2 {
319            return Err(crate::Error::NoCompletedTrials);
320        }
321
322        // Collect all parameter IDs in a stable order.
323        let all_param_ids: Vec<_> = {
324            let set: BTreeSet<_> = complete.iter().flat_map(|t| t.params.keys()).collect();
325            set.into_iter().collect()
326        };
327
328        if all_param_ids.is_empty() {
329            return Ok(crate::fanova::FanovaResult {
330                main_effects: Vec::new(),
331                interactions: Vec::new(),
332            });
333        }
334
335        // Build feature matrix (only trials that have all parameters).
336        let mut data = Vec::with_capacity(complete.len());
337        let mut targets = Vec::with_capacity(complete.len());
338
339        for trial in &complete {
340            let mut row = Vec::with_capacity(all_param_ids.len());
341            let mut has_all = true;
342
343            for &pid in &all_param_ids {
344                if let Some(pv) = trial.params.get(pid) {
345                    row.push(match *pv {
346                        ParamValue::Float(v) => v,
347                        ParamValue::Int(v) => v as f64,
348                        ParamValue::Categorical(v) => v as f64,
349                    });
350                } else {
351                    has_all = false;
352                    break;
353                }
354            }
355
356            if has_all {
357                data.push(row);
358                targets.push(trial.value.clone().into());
359            }
360        }
361
362        if data.len() < 2 {
363            return Err(crate::Error::NoCompletedTrials);
364        }
365
366        // Build feature names from parameter labels.
367        let feature_names: Vec<String> = all_param_ids
368            .iter()
369            .map(|&pid| {
370                complete
371                    .iter()
372                    .find_map(|t| t.param_labels.get(pid))
373                    .map_or_else(|| pid.to_string(), Clone::clone)
374            })
375            .collect();
376
377        Ok(compute_fanova(&data, &targets, &feature_names, config))
378    }
379}