Skip to content

Commit 4af515d

Browse files
authored
Merge pull request #1 from alexshtf/early_stop_mode
Add mode and min_delta to early_stop
2 parents ba3c0ea + 4c0a96a commit 4af515d

4 files changed

Lines changed: 87 additions & 7 deletions

File tree

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,12 +120,15 @@ events = pipe(
120120
epoch_stream(...),
121121
augment(validation_loss(...)),
122122
take(500), # safety cap
123-
early_stop(key="val_loss", patience=10),
123+
early_stop(key="val_loss", patience=10, mode="min", min_delta=1e-4),
124124
)
125125
for event in events:
126126
print(event)
127127
```
128128

129+
`mode="min"` is the default. Use `mode="max"` for metrics such as accuracy, and set
130+
`min_delta` to ignore tiny noisy changes.
131+
129132
## Side effects
130133
Sometimes you want to log metrics (or write to an external system) without changing the stream. Use `tap(fn)`:
131134
```python

docs/tutorial.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ events = pipe(
241241
epoch_stream((x_train, y_train), model, optimizer, loss_fn, batch_size=512, shuffle=True),
242242
augment(validation_loss((x_val, y_val), loss_fn)),
243243
take(500),
244-
early_stop(key="val_loss", patience=10),
244+
early_stop(key="val_loss", patience=10, mode="min", min_delta=1e-4),
245245
)
246246

247247
history = list(events) # finite now
@@ -250,9 +250,22 @@ print("stopped at epoch", history[-1]["step"])
250250

251251
Notes:
252252

253-
- `early_stop(..., key="val_loss", ...)` assumes “lower is better”.
253+
- `early_stop(..., mode="min")` (the default) treats lower values as better.
254+
- Use `mode="max"` for metrics where higher is better (for example `val_acc`).
255+
- `min_delta` is an absolute threshold for improvement, so tiny metric noise does not reset patience.
254256
- It yields events up to (and including) the epoch that triggers stopping.
255257

258+
For an accuracy metric, switch to `mode="max"`:
259+
260+
```python
261+
events = pipe(
262+
epoch_stream(...),
263+
augment(...), # produce "val_acc" on each event
264+
take(500),
265+
early_stop(key="val_acc", patience=10, mode="max", min_delta=1e-3),
266+
)
267+
```
268+
256269
## 6) Become a hero: write your own augmenter
257270

258271
An **augmenter** is a function `event -> dict` (or `None`) that adds keys to the event. This is great for:
@@ -462,7 +475,7 @@ events = pipe(
462475
ema("val_loss", alpha=0.2),
463476
print_every(10, keys=("train_loss", "val_loss", "val_loss_ema", "param_l2")),
464477
take(500),
465-
early_stop(key="val_loss", patience=20),
478+
early_stop(key="val_loss", patience=20, mode="min", min_delta=1e-4),
466479
)
467480

468481
# Write the whole training history to disk (one JSON object per line).

src/fitstream/fit.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
from collections.abc import Callable, Iterable, Iterator, Sequence
4-
from typing import Any
4+
from typing import Any, Literal
55
import time
66

77
import torch
@@ -108,22 +108,43 @@ def stage(events: Iterable[dict[str, Any]]) -> Iterable[dict[str, Any]]:
108108
def early_stop(
109109
key: str,
110110
patience: int,
111+
*,
112+
mode: Literal["min", "max"] = "min",
113+
min_delta: float = 0.0,
111114
) -> Transform:
112115
"""Yield events until the metric stops improving for `patience` steps.
113116
117+
Args:
118+
key: Event key containing the monitored metric.
119+
patience: Number of consecutive non-improving events tolerated before stopping.
120+
mode: Improvement direction. ``"min"`` means lower is better, ``"max"`` means
121+
higher is better.
122+
min_delta: Minimum absolute change required to count as an improvement.
123+
114124
Use as a pipe stage:
115125
116126
- ``pipe(events, early_stop(key="val_loss", patience=10))``
117127
"""
118128
if patience < 1:
119129
raise ValueError("patience must be >= 1.")
130+
if mode not in {"min", "max"}:
131+
raise ValueError("mode must be one of {'min', 'max'}.")
132+
if min_delta < 0.0:
133+
raise ValueError("min_delta must be >= 0.")
120134

121135
def apply(stream: Iterable[dict[str, Any]]) -> Iterable[dict[str, Any]]:
122-
best = float("inf")
136+
best: float | None = None
123137
bad = 0
124138
for event in stream:
125139
value = float(event[key])
126-
if value < best:
140+
if best is None:
141+
improved = True
142+
elif mode == "min":
143+
improved = value < (best - min_delta)
144+
else:
145+
improved = value > (best + min_delta)
146+
147+
if improved:
127148
best = value
128149
bad = 0
129150
else:

tests/test_early_stop.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,49 @@ def test_early_stop_rejects_invalid_patience() -> None:
1616
early_stop(key="val_loss", patience=0)
1717

1818

19+
def test_early_stop_explicit_min_mode_matches_default() -> None:
20+
events = [{"val_loss": loss} for loss in [5.0, 4.0, 4.0, 4.0, 3.0]]
21+
22+
result_default = list(early_stop(key="val_loss", patience=2)(events))
23+
result_explicit = list(early_stop(key="val_loss", patience=2, mode="min", min_delta=0.0)(events))
24+
25+
assert result_explicit == result_default
26+
27+
28+
def test_early_stop_supports_max_mode() -> None:
29+
events = [{"val_acc": value} for value in [0.50, 0.60, 0.60, 0.59, 0.70]]
30+
31+
result = list(early_stop(key="val_acc", patience=2, mode="max")(events))
32+
33+
assert [event["val_acc"] for event in result] == [0.50, 0.60, 0.60, 0.59]
34+
35+
36+
def test_early_stop_min_delta_for_min_mode() -> None:
37+
events = [{"val_loss": value} for value in [1.00, 0.95, 0.92, 0.85]]
38+
39+
result = list(early_stop(key="val_loss", patience=2, min_delta=0.1)(events))
40+
41+
assert [event["val_loss"] for event in result] == [1.00, 0.95, 0.92]
42+
43+
44+
def test_early_stop_min_delta_for_max_mode() -> None:
45+
events = [{"val_acc": value} for value in [0.50, 0.53, 0.56, 0.57, 0.58]]
46+
47+
result = list(early_stop(key="val_acc", patience=2, mode="max", min_delta=0.05)(events))
48+
49+
assert [event["val_acc"] for event in result] == [0.50, 0.53, 0.56, 0.57, 0.58]
50+
51+
52+
def test_early_stop_rejects_invalid_mode() -> None:
53+
with pytest.raises(ValueError):
54+
early_stop(key="val_loss", patience=2, mode="lower") # type: ignore[arg-type]
55+
56+
57+
def test_early_stop_rejects_negative_min_delta() -> None:
58+
with pytest.raises(ValueError):
59+
early_stop(key="val_loss", patience=2, min_delta=-0.1)
60+
61+
1962
def test_early_stop_is_pipe_stage() -> None:
2063
events = [{"val_loss": loss} for loss in [5.0, 4.0, 4.0, 4.0, 3.0]]
2164

0 commit comments

Comments
 (0)