Skip to main content

burn_train/learner/
early_stopping.rs

1use crate::metric::{
2    Metric, MetricName,
3    store::{Aggregate, Direction, EventStoreClient, Split},
4};
5
6/// The condition that [early stopping strategies](EarlyStoppingStrategy) should follow.
7#[derive(Clone)]
8pub enum StoppingCondition {
9    /// When no improvement has happened since the given number of epochs.
10    NoImprovementSince {
11        /// The number of epochs allowed to worsen before it gets better.
12        n_epochs: usize,
13    },
14}
15
16/// A strategy that checks if the training should be stopped.
17pub trait EarlyStoppingStrategy: Send {
18    /// Update its current state and returns if the training should be stopped.
19    fn should_stop(&mut self, epoch: usize, store: &EventStoreClient) -> bool;
20}
21
22/// A helper trait to provide type-erased cloning.
23pub trait CloneEarlyStoppingStrategy: EarlyStoppingStrategy + Send {
24    /// Clone into a boxed trait object.
25    fn clone_box(&self) -> Box<dyn CloneEarlyStoppingStrategy>;
26}
27
28/// Blanket-implement `CloneEarlyStoppingStrategy` for any `T` that
29/// already implements your strategy + `Clone` + `Send` + `'static`.
30impl<T> CloneEarlyStoppingStrategy for T
31where
32    T: EarlyStoppingStrategy + Clone + Send + 'static,
33{
34    fn clone_box(&self) -> Box<dyn CloneEarlyStoppingStrategy> {
35        Box::new(self.clone())
36    }
37}
38
39/// Now you can `impl Clone` for the boxed trait object.
40impl Clone for Box<dyn CloneEarlyStoppingStrategy> {
41    fn clone(&self) -> Box<dyn CloneEarlyStoppingStrategy> {
42        self.clone_box()
43    }
44}
45
46/// An [early stopping strategy](EarlyStoppingStrategy) based on a metrics collected
47/// during training or validation.
48#[derive(Clone)]
49pub struct MetricEarlyStoppingStrategy {
50    condition: StoppingCondition,
51    metric_name: MetricName,
52    aggregate: Aggregate,
53    direction: Direction,
54    split: Split,
55    best_epoch: usize,
56    best_value: f64,
57    warmup_epochs: Option<usize>,
58}
59
60impl EarlyStoppingStrategy for MetricEarlyStoppingStrategy {
61    fn should_stop(&mut self, epoch: usize, store: &EventStoreClient) -> bool {
62        let current_value =
63            match store.find_metric(&self.metric_name, epoch, self.aggregate, &self.split) {
64                Some(value) => value,
65                None => {
66                    log::warn!("Can't find metric for early stopping.");
67                    return false;
68                }
69            };
70
71        let is_best = match self.direction {
72            Direction::Lowest => current_value < self.best_value,
73            Direction::Highest => current_value > self.best_value,
74        };
75
76        if is_best {
77            log::info!(
78                "New best epoch found {} {}: {}",
79                epoch,
80                self.metric_name,
81                current_value
82            );
83            self.best_value = current_value;
84            self.best_epoch = epoch;
85            return false;
86        }
87
88        if let Some(warmup_epochs) = self.warmup_epochs
89            && epoch <= warmup_epochs
90        {
91            return false;
92        }
93
94        match self.condition {
95            StoppingCondition::NoImprovementSince { n_epochs } => {
96                let should_stop = epoch - self.best_epoch >= n_epochs;
97
98                if should_stop {
99                    log::info!(
100                        "Stopping training loop, no improvement since epoch {}, {}: {},  current \
101                         epoch {}, {}: {}",
102                        self.best_epoch,
103                        self.metric_name,
104                        self.best_value,
105                        epoch,
106                        self.metric_name,
107                        current_value
108                    );
109                }
110
111                should_stop
112            }
113        }
114    }
115}
116
117impl MetricEarlyStoppingStrategy {
118    /// Create a new [early stopping strategy](EarlyStoppingStrategy) based on a metrics collected
119    /// during training or validation.
120    ///
121    /// # Notes
122    ///
123    /// The metric should be registered for early stopping to work, otherwise no data is collected.
124    pub fn new<Me: Metric>(
125        metric: &Me,
126        aggregate: Aggregate,
127        direction: Direction,
128        split: Split,
129        condition: StoppingCondition,
130    ) -> Self {
131        let init_value = match direction {
132            Direction::Lowest => f64::MAX,
133            Direction::Highest => f64::MIN,
134        };
135
136        Self {
137            metric_name: metric.name(),
138            condition,
139            aggregate,
140            direction,
141            split,
142            best_epoch: 1,
143            best_value: init_value,
144            warmup_epochs: None,
145        }
146    }
147
148    /// Get the warmup period.
149    ///
150    /// Early stopping will not trigger during the warmup epochs.
151    pub fn warmup_epochs(&self) -> Option<usize> {
152        self.warmup_epochs
153    }
154
155    /// Set the warmup epochs.
156    ///
157    /// Early stopping will not trigger during the warmup epochs.
158    ///
159    /// # Arguments
160    /// - `warmup`: the number of warmup epochs, or None.
161    pub fn with_warmup_epochs(self, warmup: Option<usize>) -> Self {
162        Self {
163            warmup_epochs: warmup,
164            ..self
165        }
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use std::sync::Arc;
172
173    use crate::{
174        EventProcessorTraining,
175        logger::InMemoryMetricLogger,
176        metric::{
177            LossMetric,
178            processor::{
179                MetricsTraining, MinimalEventProcessor,
180                test_utils::{end_epoch, process_train},
181            },
182            store::LogEventStore,
183        },
184        test_utils::start_epoch,
185    };
186
187    use super::*;
188
189    #[test]
190    fn never_early_stop_while_it_is_improving() {
191        test_early_stopping(
192            None,
193            1,
194            &[
195                (&[0.5, 0.3], false, "Should not stop first epoch"),
196                (&[0.4, 0.3], false, "Should not stop when improving"),
197                (&[0.3, 0.3], false, "Should not stop when improving"),
198                (&[0.2, 0.3], false, "Should not stop when improving"),
199            ],
200        );
201    }
202
203    #[test]
204    fn early_stop_when_no_improvement_since_two_epochs() {
205        test_early_stopping(
206            None,
207            2,
208            &[
209                (&[1.0, 0.5], false, "Should not stop first epoch"),
210                (&[0.5, 0.3], false, "Should not stop when improving"),
211                (
212                    &[1.0, 3.0],
213                    false,
214                    "Should not stop first time it gets worse",
215                ),
216                (
217                    &[1.0, 2.0],
218                    true,
219                    "Should stop since two following epochs didn't improve",
220                ),
221            ],
222        );
223    }
224
225    #[test]
226    fn early_stopping_with_warmup() {
227        test_early_stopping(
228            Some(3),
229            2,
230            &[
231                (&[1.0, 0.5], false, "Should not stop during warmup"),
232                (&[1.0, 0.5], false, "Should not stop during warmup"),
233                (&[1.0, 0.5], false, "Should not stop during warmup"),
234                (
235                    &[1.0, 0.5],
236                    true,
237                    "Should stop when not improving after warmup",
238                ),
239            ],
240        )
241    }
242
243    #[test]
244    fn early_stop_when_stays_equal() {
245        test_early_stopping(
246            None,
247            2,
248            &[
249                (&[0.5, 0.3], false, "Should not stop first epoch"),
250                (
251                    &[0.5, 0.3],
252                    false,
253                    "Should not stop first time it stars the same",
254                ),
255                (
256                    &[0.5, 0.3],
257                    true,
258                    "Should stop since two following epochs didn't improve",
259                ),
260            ],
261        );
262    }
263
264    fn test_early_stopping(warmup: Option<usize>, n_epochs: usize, data: &[(&[f64], bool, &str)]) {
265        let loss = LossMetric::new();
266        let mut early_stopping = MetricEarlyStoppingStrategy::new(
267            &loss,
268            Aggregate::Mean,
269            Direction::Lowest,
270            Split::Train,
271            StoppingCondition::NoImprovementSince { n_epochs },
272        )
273        .with_warmup_epochs(warmup);
274        let mut store = LogEventStore::default();
275        let mut metrics = MetricsTraining::<f64, f64>::default();
276
277        store.register_logger(InMemoryMetricLogger::default());
278        metrics.register_train_metric_numeric(loss);
279
280        let store = Arc::new(EventStoreClient::new(store));
281        let mut processor = MinimalEventProcessor::new(metrics, store.clone());
282
283        processor
284            .process_train(crate::LearnerEvent::Start {
285                total_epochs: 0,
286                starting_epoch: 0,
287                label: None,
288            })
289            .unwrap();
290        for (epoch, (points, should_start, comment)) in (1..).zip(data.iter()) {
291            start_epoch(&mut processor, epoch, points.len());
292            for point in points.iter() {
293                process_train(&mut processor, *point, epoch);
294            }
295            end_epoch(&mut processor, epoch);
296
297            assert_eq!(
298                *should_start,
299                early_stopping.should_stop(epoch, &store),
300                "{comment}"
301            );
302        }
303    }
304}