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 ¶m_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(¶m_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}