Skip to main content

hyperopt_core/
study.rs

1use crate::{
2    Direction, HyperoptError, ObjectiveError, ObjectiveResult, Pruner, Sampler, Storage,
3    StudyMetadata, StudyState, Trial, TrialContext, TrialState,
4};
5use std::panic::{catch_unwind, AssertUnwindSafe};
6use std::sync::Mutex;
7
8/// A single optimization run: a direction, a sampler, a pruner, and a storage
9/// backend, tied to a named study whose trials live in that storage.
10///
11/// Construct one with [`Study::new`], or use the ergonomic builder in the
12/// `hyperopt` facade crate. Run trials with [`Study::optimize`] (sequential) or
13/// [`Study::optimize_parallel`] (feature `parallel`).
14pub struct Study {
15    name: String,
16    direction: Direction,
17    sampler: Mutex<Box<dyn Sampler>>,
18    pruner: Box<dyn Pruner>,
19    storage: Box<dyn Storage>,
20}
21
22impl Study {
23    /// Assemble a study from its parts. Persists study metadata immediately so
24    /// the direction survives a reload. If the study already exists in
25    /// `storage`, its recorded direction is authoritative and is adopted here
26    /// (so a resumed study can't silently flip direction).
27    pub fn new(
28        name: impl Into<String>,
29        direction: Direction,
30        sampler: Box<dyn Sampler>,
31        pruner: Box<dyn Pruner>,
32        storage: Box<dyn Storage>,
33    ) -> Result<Self, HyperoptError> {
34        let name = name.into();
35        let direction = match storage.load_study_metadata(&name)? {
36            Some(meta) => meta.direction,
37            None => {
38                storage.save_study_metadata(&StudyMetadata {
39                    study_name: name.clone(),
40                    direction,
41                })?;
42                direction
43            }
44        };
45        Ok(Study {
46            name,
47            direction,
48            sampler: Mutex::new(sampler),
49            pruner,
50            storage,
51        })
52    }
53
54    /// The study's name (its key in storage).
55    pub fn name(&self) -> &str {
56        &self.name
57    }
58
59    /// The optimization direction.
60    pub fn direction(&self) -> Direction {
61        self.direction
62    }
63
64    /// A fresh snapshot of every trial recorded for this study.
65    pub fn trials(&self) -> Result<Vec<Trial>, HyperoptError> {
66        Ok(self.storage.load_trials(&self.name)?)
67    }
68
69    /// The best completed trial under the study direction, if any.
70    pub fn best_trial(&self) -> Result<Option<Trial>, HyperoptError> {
71        let trials = self.storage.load_trials(&self.name)?;
72        let state = StudyState::new(self.direction, trials);
73        Ok(state.best_trial().cloned())
74    }
75
76    /// The best objective value seen so far, if any trial has completed.
77    pub fn best_value(&self) -> Result<Option<f64>, HyperoptError> {
78        Ok(self.best_trial()?.and_then(|t| t.value))
79    }
80
81    /// Run `n_trials` sequentially. A trial that panics or returns
82    /// [`ObjectiveError::Failed`] is marked `Failed` and the run continues; one
83    /// that returns [`ObjectiveError::Pruned`] is marked `Pruned`.
84    pub fn optimize<F>(&self, mut objective: F, n_trials: usize) -> Result<(), HyperoptError>
85    where
86        F: FnMut(&mut TrialContext) -> ObjectiveResult,
87    {
88        for _ in 0..n_trials {
89            let existing = self.storage.load_trials(&self.name)?;
90            let number = existing.len();
91            let state = StudyState::new(self.direction, existing);
92            let mut ctx =
93                TrialContext::new(number, &self.sampler, &state, self.pruner.as_ref());
94
95            let result = catch_unwind(AssertUnwindSafe(|| objective(&mut ctx)));
96            let trial = finish_trial(ctx.into_trial(), result);
97            self.storage.save_trial(&self.name, &trial)?;
98        }
99        Ok(())
100    }
101
102    /// Run `n_trials` across `n_workers` threads via `rayon`.
103    ///
104    /// **Stale-view semantics (by design, not a bug):** under parallelism
105    /// several trials may be suggested before earlier ones finish and are
106    /// saved, so a sampler necessarily works from a *partial, slightly stale*
107    /// snapshot of study history. This matches Optuna's behaviour under
108    /// parallel execution and is why a parallel run of a study can diverge
109    /// somewhat from a sequential one — compare best-values, not exact
110    /// trajectories. The sampler is shared behind a lock and the storage
111    /// backend must be `Send + Sync`; both are audited for this.
112    #[cfg(feature = "parallel")]
113    pub fn optimize_parallel<F>(
114        &self,
115        objective: F,
116        n_trials: usize,
117        n_workers: usize,
118    ) -> Result<(), HyperoptError>
119    where
120        F: Fn(&mut TrialContext) -> ObjectiveResult + Sync,
121    {
122        use rayon::prelude::*;
123        use std::sync::atomic::{AtomicUsize, Ordering};
124
125        let base = self.storage.load_trials(&self.name)?.len();
126        let counter = AtomicUsize::new(base);
127
128        let pool = rayon::ThreadPoolBuilder::new()
129            .num_threads(n_workers)
130            .build()
131            .map_err(|e| HyperoptError::Storage(crate::StorageError::Backend(e.to_string())))?;
132
133        pool.install(|| {
134            (0..n_trials).into_par_iter().try_for_each(|_| {
135                let number = counter.fetch_add(1, Ordering::SeqCst);
136                // Each worker takes its own (possibly stale) snapshot.
137                let existing = self.storage.load_trials(&self.name)?;
138                let state = StudyState::new(self.direction, existing);
139                let mut ctx =
140                    TrialContext::new(number, &self.sampler, &state, self.pruner.as_ref());
141                let result = catch_unwind(AssertUnwindSafe(|| objective(&mut ctx)));
142                let trial = finish_trial(ctx.into_trial(), result);
143                self.storage.save_trial(&self.name, &trial)?;
144                Ok::<(), HyperoptError>(())
145            })
146        })
147    }
148}
149
150/// Fold a trial's evaluation outcome (including a caught panic) into its final
151/// state and value.
152fn finish_trial(
153    mut trial: Trial,
154    result: std::thread::Result<ObjectiveResult>,
155) -> Trial {
156    match result {
157        Ok(Ok(value)) => {
158            trial.value = Some(value);
159            trial.state = TrialState::Complete;
160        }
161        Ok(Err(ObjectiveError::Pruned)) => {
162            trial.state = TrialState::Pruned;
163        }
164        Ok(Err(ObjectiveError::Failed(_))) => {
165            trial.state = TrialState::Failed;
166        }
167        Err(_panic) => {
168            // A single bad trial shouldn't abort a long-running study.
169            trial.state = TrialState::Failed;
170        }
171    }
172    trial
173}