Skip to content

Commit 80ae4f1

Browse files
author
1
committed
feat: add early stopping recommendation engine
- EarlyStopping class with configurable patience and min_delta - Supports min/max mode optimization - recommend_patience() heuristic for loss curves - 22 new tests (150 total) Closes #2
1 parent 70900e4 commit 80ae4f1

3 files changed

Lines changed: 356 additions & 0 deletions

File tree

src/trainpulse/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,20 @@
1010
TrainpulseError,
1111
)
1212
from trainpulse.callbacks import TrainingCallback
13+
from trainpulse.early_stopping import EarlyStopping, EarlyStopResult, recommend_patience
1314
from trainpulse.monitor import Monitor
1415

1516
__all__ = [
1617
"Alert",
1718
"AlertSeverity",
19+
"EarlyStopping",
20+
"EarlyStopResult",
1821
"MetricSnapshot",
1922
"MetricType",
2023
"Monitor",
2124
"MonitorConfig",
2225
"TrainingCallback",
2326
"TrainingReport",
2427
"TrainpulseError",
28+
"recommend_patience",
2529
]

src/trainpulse/early_stopping.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""Early stopping recommendation engine."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
from typing import List, Literal
7+
8+
9+
@dataclass
10+
class EarlyStopResult:
11+
"""Result from a single early-stopping step."""
12+
13+
step: int
14+
value: float
15+
should_stop: bool
16+
improved: bool
17+
best_value: float
18+
best_step: int
19+
20+
21+
class EarlyStopping:
22+
"""Track a metric and recommend when to stop training.
23+
24+
Parameters
25+
----------
26+
patience:
27+
Number of steps without improvement before recommending stop.
28+
min_delta:
29+
Minimum change from the best value to count as an improvement.
30+
metric:
31+
Name of the metric being tracked (informational).
32+
mode:
33+
``"min"`` treats lower values as better; ``"max"`` treats higher as better.
34+
"""
35+
36+
def __init__(
37+
self,
38+
patience: int = 5,
39+
min_delta: float = 0.001,
40+
metric: str = "loss",
41+
mode: Literal["min", "max"] = "min",
42+
) -> None:
43+
if patience < 1:
44+
raise ValueError("patience must be >= 1")
45+
if mode not in ("min", "max"):
46+
raise ValueError("mode must be 'min' or 'max'")
47+
self._patience = patience
48+
self._min_delta = abs(min_delta)
49+
self._metric = metric
50+
self._mode = mode
51+
52+
self._best_value: float | None = None
53+
self._best_step: int = 0
54+
self._current_step: int = 0
55+
self._steps_without_improvement: int = 0
56+
57+
# ------------------------------------------------------------------
58+
# Public API
59+
# ------------------------------------------------------------------
60+
61+
def step(self, value: float) -> EarlyStopResult:
62+
"""Record a new metric value and return the stop recommendation."""
63+
step_idx = self._current_step
64+
self._current_step += 1
65+
66+
improved = self._is_improvement(value)
67+
if improved:
68+
self._best_value = value
69+
self._best_step = step_idx
70+
self._steps_without_improvement = 0
71+
else:
72+
self._steps_without_improvement += 1
73+
74+
return EarlyStopResult(
75+
step=step_idx,
76+
value=value,
77+
should_stop=self._steps_without_improvement >= self._patience,
78+
improved=improved,
79+
best_value=self._best_value, # type: ignore[arg-type]
80+
best_step=self._best_step,
81+
)
82+
83+
@property
84+
def should_stop(self) -> bool:
85+
"""``True`` if patience has been exhausted."""
86+
return self._steps_without_improvement >= self._patience
87+
88+
@property
89+
def best_value(self) -> float | None:
90+
"""Best metric value observed so far, or ``None`` if no values logged."""
91+
return self._best_value
92+
93+
@property
94+
def best_step(self) -> int:
95+
"""Step index where the best value was recorded."""
96+
return self._best_step
97+
98+
@property
99+
def steps_without_improvement(self) -> int:
100+
return self._steps_without_improvement
101+
102+
# ------------------------------------------------------------------
103+
# Internals
104+
# ------------------------------------------------------------------
105+
106+
def _is_improvement(self, value: float) -> bool:
107+
if self._best_value is None:
108+
return True
109+
if self._mode == "min":
110+
return value < self._best_value - self._min_delta
111+
return value > self._best_value + self._min_delta
112+
113+
114+
def recommend_patience(loss_history: List[float]) -> int:
115+
"""Heuristic that recommends a patience value from a loss curve.
116+
117+
The recommendation is based on the typical gap (in steps) between
118+
successive improvements and the relative amplitude of those
119+
improvements. A noisier curve with infrequent improvements gets a
120+
higher patience so the run isn't killed prematurely.
121+
122+
Parameters
123+
----------
124+
loss_history:
125+
Sequence of loss values (one per step), assumed to be in temporal
126+
order.
127+
128+
Returns
129+
-------
130+
int
131+
Recommended patience (always >= 1).
132+
"""
133+
if len(loss_history) < 2:
134+
return 5 # sensible default
135+
136+
# Find steps where a new minimum was reached.
137+
improvement_gaps: list[int] = []
138+
best = loss_history[0]
139+
last_improvement_step = 0
140+
141+
for i in range(1, len(loss_history)):
142+
if loss_history[i] < best:
143+
gap = i - last_improvement_step
144+
improvement_gaps.append(gap)
145+
best = loss_history[i]
146+
last_improvement_step = i
147+
148+
if not improvement_gaps:
149+
# No improvements at all — recommend a conservative patience equal
150+
# to half the history length (at least 5).
151+
return max(5, len(loss_history) // 2)
152+
153+
mean_gap = sum(improvement_gaps) / len(improvement_gaps)
154+
max_gap = max(improvement_gaps)
155+
156+
# Blend average and max gap so patience tolerates occasional long dry
157+
# spells. The multiplier (1.5×) provides a safety margin.
158+
patience = int(0.5 * mean_gap + 0.5 * max_gap) + 1
159+
patience = int(patience * 1.5)
160+
161+
# Clamp to a reasonable range.
162+
return max(3, min(patience, len(loss_history)))

tests/test_early_stopping.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
"""Tests for the early stopping recommendation engine."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from trainpulse.early_stopping import EarlyStopping, EarlyStopResult, recommend_patience
8+
9+
10+
# ──────────────────────────────────────────────────────────────────────
11+
# EarlyStopResult dataclass
12+
# ──────────────────────────────────────────────────────────────────────
13+
14+
15+
class TestEarlyStopResult:
16+
def test_fields(self):
17+
r = EarlyStopResult(step=0, value=0.5, should_stop=False, improved=True, best_value=0.5, best_step=0)
18+
assert r.step == 0
19+
assert r.value == 0.5
20+
assert r.should_stop is False
21+
assert r.improved is True
22+
assert r.best_value == 0.5
23+
assert r.best_step == 0
24+
25+
26+
# ──────────────────────────────────────────────────────────────────────
27+
# EarlyStopping — core behaviour
28+
# ──────────────────────────────────────────────────────────────────────
29+
30+
31+
class TestEarlyStoppingMinMode:
32+
def test_first_step_always_improves(self):
33+
es = EarlyStopping(patience=3, min_delta=0.0)
34+
r = es.step(1.0)
35+
assert r.improved is True
36+
assert r.should_stop is False
37+
assert r.best_value == 1.0
38+
assert r.best_step == 0
39+
40+
def test_monotonic_decrease_never_stops(self):
41+
es = EarlyStopping(patience=3, min_delta=0.0)
42+
for i in range(20):
43+
r = es.step(1.0 - i * 0.01)
44+
assert r.should_stop is False
45+
assert es.should_stop is False
46+
47+
def test_patience_exhausted(self):
48+
es = EarlyStopping(patience=3, min_delta=0.0)
49+
es.step(1.0) # best
50+
es.step(1.1)
51+
es.step(1.2)
52+
r = es.step(1.3) # 3rd non-improvement → stop
53+
assert r.should_stop is True
54+
assert es.should_stop is True
55+
56+
def test_improvement_resets_counter(self):
57+
es = EarlyStopping(patience=3, min_delta=0.0)
58+
es.step(1.0)
59+
es.step(1.1) # +1
60+
es.step(1.2) # +2
61+
es.step(0.5) # improvement → reset
62+
assert es.steps_without_improvement == 0
63+
assert es.best_value == 0.5
64+
65+
def test_min_delta_respected(self):
66+
es = EarlyStopping(patience=3, min_delta=0.1)
67+
es.step(1.0)
68+
r = es.step(0.95) # only 0.05 better, below delta
69+
assert r.improved is False
70+
r = es.step(0.89) # 0.11 better than best (1.0) → improved
71+
assert r.improved is True
72+
73+
def test_best_step_tracks_correctly(self):
74+
es = EarlyStopping(patience=5, min_delta=0.0)
75+
es.step(1.0) # step 0
76+
es.step(0.8) # step 1
77+
es.step(0.9) # step 2
78+
es.step(0.7) # step 3
79+
assert es.best_step == 3
80+
assert es.best_value == 0.7
81+
82+
83+
class TestEarlyStoppingMaxMode:
84+
def test_max_mode_improves_upward(self):
85+
es = EarlyStopping(patience=3, min_delta=0.0, mode="max")
86+
es.step(0.5)
87+
r = es.step(0.8)
88+
assert r.improved is True
89+
assert es.best_value == 0.8
90+
91+
def test_max_mode_stops_on_decline(self):
92+
es = EarlyStopping(patience=2, min_delta=0.0, mode="max")
93+
es.step(0.9)
94+
es.step(0.8)
95+
r = es.step(0.7)
96+
assert r.should_stop is True
97+
98+
def test_max_mode_min_delta(self):
99+
es = EarlyStopping(patience=3, min_delta=0.1, mode="max")
100+
es.step(1.0)
101+
r = es.step(1.05) # only +0.05, below delta
102+
assert r.improved is False
103+
104+
105+
# ──────────────────────────────────────────────────────────────────────
106+
# EarlyStopping — edge cases & validation
107+
# ──────────────────────────────────────────────────────────────────────
108+
109+
110+
class TestEarlyStoppingEdgeCases:
111+
def test_patience_one(self):
112+
es = EarlyStopping(patience=1, min_delta=0.0)
113+
es.step(1.0)
114+
r = es.step(1.0) # no improvement
115+
assert r.should_stop is True
116+
117+
def test_invalid_patience_raises(self):
118+
with pytest.raises(ValueError, match="patience"):
119+
EarlyStopping(patience=0)
120+
121+
def test_invalid_mode_raises(self):
122+
with pytest.raises(ValueError, match="mode"):
123+
EarlyStopping(mode="average") # type: ignore[arg-type]
124+
125+
def test_properties_before_any_step(self):
126+
es = EarlyStopping()
127+
assert es.best_value is None
128+
assert es.best_step == 0
129+
assert es.should_stop is False
130+
assert es.steps_without_improvement == 0
131+
132+
def test_step_indices_increment(self):
133+
es = EarlyStopping(patience=10)
134+
results = [es.step(float(i)) for i in range(5)]
135+
assert [r.step for r in results] == [0, 1, 2, 3, 4]
136+
137+
def test_equal_values_are_not_improvement_min(self):
138+
es = EarlyStopping(patience=3, min_delta=0.0)
139+
es.step(1.0)
140+
r = es.step(1.0)
141+
assert r.improved is False
142+
143+
def test_equal_values_are_not_improvement_max(self):
144+
es = EarlyStopping(patience=3, min_delta=0.0, mode="max")
145+
es.step(1.0)
146+
r = es.step(1.0)
147+
assert r.improved is False
148+
149+
150+
# ──────────────────────────────────────────────────────────────────────
151+
# recommend_patience
152+
# ──────────────────────────────────────────────────────────────────────
153+
154+
155+
class TestRecommendPatience:
156+
def test_returns_default_for_short_history(self):
157+
assert recommend_patience([]) == 5
158+
assert recommend_patience([0.5]) == 5
159+
160+
def test_monotonically_decreasing_loss(self):
161+
history = [1.0 - 0.01 * i for i in range(100)]
162+
p = recommend_patience(history)
163+
# Every step improves → gaps are all 1 → patience should be small.
164+
assert 3 <= p <= 10
165+
166+
def test_flat_loss_gives_high_patience(self):
167+
history = [1.0] * 50
168+
p = recommend_patience(history)
169+
# No improvements at all → conservative patience.
170+
assert p >= 5
171+
172+
def test_noisy_loss_with_occasional_drops(self):
173+
# Spiky loss that drops every ~20 steps.
174+
import random
175+
176+
rng = random.Random(42)
177+
history: list[float] = []
178+
base = 1.0
179+
for i in range(100):
180+
if i > 0 and i % 20 == 0:
181+
base -= 0.1
182+
history.append(base + rng.uniform(-0.02, 0.02))
183+
p = recommend_patience(history)
184+
# Gaps ~20 steps → patience should be at least 10.
185+
assert p >= 10
186+
187+
def test_result_is_always_positive_int(self):
188+
p = recommend_patience([10.0, 9.0, 8.0])
189+
assert isinstance(p, int)
190+
assert p >= 1

0 commit comments

Comments
 (0)