1use crate::metric::{
2 Metric, MetricName,
3 store::{Aggregate, Direction, EventStoreClient, Split},
4};
5
6#[derive(Clone)]
8pub enum StoppingCondition {
9 NoImprovementSince {
11 n_epochs: usize,
13 },
14}
15
16pub trait EarlyStoppingStrategy: Send {
18 fn should_stop(&mut self, epoch: usize, store: &EventStoreClient) -> bool;
20}
21
22pub trait CloneEarlyStoppingStrategy: EarlyStoppingStrategy + Send {
24 fn clone_box(&self) -> Box<dyn CloneEarlyStoppingStrategy>;
26}
27
28impl<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
39impl Clone for Box<dyn CloneEarlyStoppingStrategy> {
41 fn clone(&self) -> Box<dyn CloneEarlyStoppingStrategy> {
42 self.clone_box()
43 }
44}
45
46#[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 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 pub fn warmup_epochs(&self) -> Option<usize> {
152 self.warmup_epochs
153 }
154
155 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}