Skip to content

Commit 0922db6

Browse files
authored
Merge pull request #3 from alexshtf/ema_tap
Add ema stage, tap frequency setting, and logging helpers
2 parents 729d33a + 5d7d720 commit 0922db6

7 files changed

Lines changed: 343 additions & 51 deletions

File tree

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,14 +130,15 @@ for event in events:
130130
`min_delta` to ignore tiny noisy changes.
131131

132132
## Side effects
133-
Sometimes you want to log metrics (or write to an external system) without changing the stream. Use `tap(fn)`:
133+
Sometimes you want to log metrics (or write to an external system) without changing the stream.
134+
Use `tap(fn, every=...)` and the built-in `print_keys(...)` helper:
134135
```python
135-
from fitstream import epoch_stream, pipe, tap, take
136+
from fitstream import epoch_stream, pipe, print_keys, tap, take
136137

137138
events = pipe(
138139
epoch_stream(...),
139-
tap(lambda ev: print(ev["step"], ev["train_loss"])),
140-
take(10),
140+
tap(print_keys("train_loss"), every=5),
141+
take(20),
141142
)
142143
list(events)
143144
```

docs/tutorial.md

Lines changed: 56 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -344,20 +344,20 @@ across events:
344344

345345
### 7.1 Quick side effects with `tap(...)`
346346

347-
FitStream includes a small helper for side effects: `tap(fn)` calls `fn(event)` and yields the event unchanged.
348-
It’s perfect for lightweight logging or writing metrics to an external system.
347+
FitStream includes a small helper for side effects: `tap(fn, every=...)` calls `fn(event)` every N events and yields
348+
the event unchanged. It’s perfect for lightweight logging or writing metrics to an external system.
349349

350350
```python
351-
from fitstream import augment, epoch_stream, pipe, take, tap, validation_loss
351+
from fitstream import augment, epoch_stream, pipe, print_keys, take, tap, validation_loss
352352

353353
events = pipe(
354354
epoch_stream((x_train, y_train), model, optimizer, loss_fn, batch_size=512, shuffle=True),
355355
augment(validation_loss((x_val, y_val), loss_fn)),
356-
tap(lambda event: print(f"epoch={event['step']:03d} val_loss={event['val_loss']:.4f}")),
356+
tap(print_keys("train_loss", "val_loss"), every=5),
357357
)
358358

359359
# Consume a few events to actually run it (streams are lazy).
360-
list(take(5)(events))
360+
list(take(15)(events))
361361
```
362362

363363
### 7.2 Learning rate scheduling with `tick(...)`
@@ -390,48 +390,49 @@ If your scheduler needs a metric (e.g. `ReduceLROnPlateau`), use `tap(...)` inst
390390

391391
### 7.3 Example: exponential moving average (EMA)
392392

393-
This stage adds a new key like `val_loss_ema` to each event.
393+
FitStream includes an `ema(...)` stage that adds a new key like `val_loss_ema` to each event.
394394

395395
```python
396-
from collections.abc import Iterable
397-
from typing import Any
398-
399-
def ema(key: str, *, alpha: float = 0.2, out_key: str | None = None):
400-
if not (0.0 < alpha <= 1.0):
401-
raise ValueError("alpha must be in (0, 1].")
402-
out_key = out_key or f"{key}_ema"
403-
404-
def stage(events: Iterable[dict[str, Any]]):
405-
value_ema: float | None = None
406-
for event in events:
407-
value = float(event[key])
408-
value_ema = value if value_ema is None else (1.0 - alpha) * value_ema + alpha * value
409-
yield event | {out_key: value_ema}
410-
411-
return stage
396+
from fitstream import augment, ema, epoch_stream, pipe
397+
398+
# Coefficient form: m = decay * m + (1 - decay) * x
399+
events = pipe(
400+
epoch_stream(...),
401+
augment(...),
402+
ema("val_loss", decay=0.9),
403+
)
404+
405+
# Half-life form (more intuitive tuning in "events until ~50% influence")
406+
events = pipe(
407+
epoch_stream(...),
408+
augment(...),
409+
ema("val_loss", half_life=10),
410+
)
412411
```
413412

414-
### 7.4 Example: print progress every N epochs
413+
`ema(..., bias_correction=True)` is the default (Adam-style correction). You can disable it:
415414

416415
```python
417-
from collections.abc import Iterable
418-
from typing import Any
419-
420-
def print_every(n: int, *, keys: tuple[str, ...] = ("train_loss", "val_loss")):
421-
if n <= 0:
422-
raise ValueError("n must be >= 1.")
423-
424-
def stage(events: Iterable[dict[str, Any]]):
425-
for event in events:
426-
if int(event["step"]) % n == 0:
427-
parts = [f"epoch={event['step']:04d}"]
428-
for k in keys:
429-
if k in event:
430-
parts.append(f"{k}={float(event[k]):.4f}")
431-
print(" ".join(parts))
432-
yield event
433-
434-
return stage
416+
from fitstream import augment, ema, epoch_stream, pipe
417+
418+
events = pipe(
419+
epoch_stream(...),
420+
augment(...),
421+
ema("val_loss", half_life=10, bias_correction=False),
422+
)
423+
```
424+
425+
### 7.4 Combine smoothing + periodic logging
426+
427+
```python
428+
from fitstream import augment, ema, epoch_stream, pipe, print_keys, tap
429+
430+
events = pipe(
431+
epoch_stream(...),
432+
augment(...),
433+
ema("val_loss", half_life=10),
434+
tap(print_keys("train_loss", "val_loss", "val_loss_ema"), every=10),
435+
)
435436
```
436437

437438
## 8) The “zero → hero” pipeline (put it all together)
@@ -453,7 +454,18 @@ from pathlib import Path
453454
import torch
454455
from torch import nn
455456

456-
from fitstream import augment, collect_jsonl, early_stop, epoch_stream, pipe, take, validation_loss
457+
from fitstream import (
458+
augment,
459+
collect_jsonl,
460+
early_stop,
461+
ema,
462+
epoch_stream,
463+
pipe,
464+
print_keys,
465+
take,
466+
tap,
467+
validation_loss,
468+
)
457469

458470
RUNS_DIR = Path("runs")
459471
RUNS_DIR.mkdir(exist_ok=True)
@@ -472,8 +484,8 @@ events = pipe(
472484
epoch_stream((x_train, y_train), model, optimizer, loss_fn, batch_size=512, shuffle=True),
473485
augment(validation_loss((x_val, y_val), loss_fn)),
474486
augment(model_param_norm),
475-
ema("val_loss", alpha=0.2),
476-
print_every(10, keys=("train_loss", "val_loss", "val_loss_ema", "param_l2")),
487+
ema("val_loss", half_life=10),
488+
tap(print_keys("train_loss", "val_loss", "val_loss_ema", "param_l2"), every=10),
477489
take(500),
478490
early_stop(key="val_loss", patience=20, mode="min", min_delta=1e-4),
479491
)

src/fitstream/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
from .events import Event as Event
33
from .fit import (
44
augment as augment,
5+
ema as ema,
56
early_stop as early_stop,
67
epoch_stream as epoch_stream,
78
pipe as pipe,
9+
print_keys as print_keys,
810
take as take,
911
tap as tap,
1012
tick as tick,

src/fitstream/fit.py

Lines changed: 105 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,19 +77,80 @@ def stage(events: Iterable[dict[str, Any]]) -> Iterable[dict[str, Any]]:
7777

7878
def tap(
7979
fn: Callable[[dict[str, Any]], Any],
80+
*,
81+
every: int = 1,
82+
start: int = 1,
8083
) -> Transform:
81-
"""Create a stage that performs side effects and yields events unchanged."""
84+
"""Create a stage that performs side effects and yields events unchanged.
85+
86+
Args:
87+
fn: Callback applied to selected events.
88+
every: Call ``fn`` every N events (event-count based).
89+
start: 1-based event index at which callback scheduling starts.
90+
"""
8291
if not callable(fn):
8392
raise TypeError("tap requires a callable.")
93+
if every < 1:
94+
raise ValueError("every must be >= 1.")
95+
if start < 1:
96+
raise ValueError("start must be >= 1.")
8497

8598
def stage(events: Iterable[dict[str, Any]]) -> Iterable[dict[str, Any]]:
86-
for event in events:
87-
fn(event)
99+
for index, event in enumerate(events, start=1):
100+
if index >= start and (index - start) % every == 0:
101+
fn(event)
88102
yield event
89103

90104
return stage
91105

92106

107+
def print_keys(
108+
*keys: str,
109+
precision: int = 4,
110+
include_step: bool = True,
111+
step_key: str = "step",
112+
) -> Callable[[dict[str, Any]], None]:
113+
"""Create an event callback that prints selected keys in one compact line.
114+
115+
Args:
116+
*keys: Event keys to print.
117+
precision: Number of digits after the decimal for numeric values.
118+
include_step: Whether to include ``step_key`` first when present.
119+
step_key: Event key used for the step prefix.
120+
"""
121+
if precision < 0:
122+
raise ValueError("precision must be >= 0.")
123+
if not keys and not include_step:
124+
raise ValueError("Provide at least one key when include_step=False.")
125+
126+
def format_value(value: Any) -> str:
127+
match value:
128+
case torch.Tensor() as tensor if tensor.numel() == 1:
129+
return f"{float(tensor.detach().cpu().item()):.{precision}f}"
130+
case bool() as boolean:
131+
return str(boolean)
132+
case int() | float() as number:
133+
return f"{float(number):.{precision}f}"
134+
case _:
135+
return str(value)
136+
137+
def callback(event: dict[str, Any]) -> None:
138+
parts: list[str] = []
139+
if include_step and step_key in event:
140+
try:
141+
parts.append(f"{step_key}={int(event[step_key]):04d}")
142+
except Exception:
143+
parts.append(f"{step_key}={event[step_key]}")
144+
for key in keys:
145+
if key in event:
146+
parts.append(f"{key}={format_value(event[key])}")
147+
else:
148+
parts.append(f"{key}=NA")
149+
print(" ".join(parts))
150+
151+
return callback
152+
153+
93154
def tick(
94155
fn: Callable[[], Any],
95156
) -> Transform:
@@ -105,6 +166,47 @@ def stage(events: Iterable[dict[str, Any]]) -> Iterable[dict[str, Any]]:
105166
return stage
106167

107168

169+
def ema(
170+
key: str,
171+
*,
172+
decay: float | None = None,
173+
half_life: float | None = None,
174+
out_key: str | None = None,
175+
bias_correction: bool = True,
176+
) -> Transform:
177+
"""Create a stage that adds an exponential moving average of ``key``.
178+
179+
Exactly one of ``decay`` or ``half_life`` must be provided.
180+
The update rule is ``m = decay * m + (1 - decay) * x`` with ``m`` initialized to 0.
181+
"""
182+
if (decay is None) == (half_life is None):
183+
raise ValueError("Provide exactly one of decay or half_life.")
184+
if half_life is not None:
185+
if half_life <= 0.0:
186+
raise ValueError("half_life must be > 0.")
187+
decay = 2.0 ** (-1.0 / half_life)
188+
assert decay is not None
189+
if not (0.0 < decay < 1.0):
190+
raise ValueError("decay must be in (0, 1).")
191+
192+
output_key = out_key or f"{key}_ema"
193+
194+
def stage(events: Iterable[dict[str, Any]]) -> Iterable[dict[str, Any]]:
195+
aggregate = 0.0
196+
t = 0
197+
for event in events:
198+
t += 1
199+
value = float(event[key])
200+
aggregate = decay * aggregate + (1.0 - decay) * value
201+
if bias_correction:
202+
smoothed = aggregate / (1.0 - (decay**t))
203+
else:
204+
smoothed = aggregate
205+
yield event | {output_key: smoothed}
206+
207+
return stage
208+
209+
108210
def early_stop(
109211
key: str,
110212
patience: int,

tests/test_ema.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import pytest
2+
3+
from fitstream import ema, pipe
4+
5+
6+
def test_ema_bias_correction_with_decay() -> None:
7+
events = [{"loss": 10.0}, {"loss": 20.0}, {"loss": 30.0}]
8+
9+
result = list(ema("loss", decay=0.5)(events))
10+
11+
observed = [event["loss_ema"] for event in result]
12+
expected = [10.0, 16.6666666667, 24.2857142857]
13+
assert observed == pytest.approx(expected)
14+
15+
16+
def test_ema_can_disable_bias_correction() -> None:
17+
events = [{"loss": 10.0}, {"loss": 20.0}, {"loss": 30.0}]
18+
19+
result = list(ema("loss", decay=0.5, bias_correction=False)(events))
20+
21+
observed = [event["loss_ema"] for event in result]
22+
expected = [5.0, 12.5, 21.25]
23+
assert observed == pytest.approx(expected)
24+
25+
26+
def test_ema_half_life_matches_equivalent_decay() -> None:
27+
events = [{"loss": 10.0}, {"loss": 20.0}, {"loss": 30.0}]
28+
29+
from_decay = list(ema("loss", decay=0.5)(events))
30+
from_half_life = list(ema("loss", half_life=1.0)(events))
31+
32+
observed_decay = [event["loss_ema"] for event in from_decay]
33+
observed_half_life = [event["loss_ema"] for event in from_half_life]
34+
assert observed_half_life == pytest.approx(observed_decay)
35+
36+
37+
def test_ema_supports_custom_output_key() -> None:
38+
events = [{"loss": 10.0}]
39+
40+
result = list(ema("loss", decay=0.5, out_key="smooth_loss")(events))
41+
42+
assert "smooth_loss" in result[0]
43+
assert "loss_ema" not in result[0]
44+
45+
46+
def test_ema_rejects_missing_decay_configuration() -> None:
47+
with pytest.raises(ValueError):
48+
ema("loss")
49+
50+
51+
def test_ema_rejects_conflicting_decay_configuration() -> None:
52+
with pytest.raises(ValueError):
53+
ema("loss", decay=0.9, half_life=10.0)
54+
55+
56+
@pytest.mark.parametrize("decay", [-0.1, 0.0, 1.0, 1.1])
57+
def test_ema_rejects_invalid_decay(decay: float) -> None:
58+
with pytest.raises(ValueError):
59+
ema("loss", decay=decay)
60+
61+
62+
@pytest.mark.parametrize("half_life", [0.0, -1.0])
63+
def test_ema_rejects_invalid_half_life(half_life: float) -> None:
64+
with pytest.raises(ValueError):
65+
ema("loss", half_life=half_life)
66+
67+
68+
def test_ema_requires_metric_key() -> None:
69+
with pytest.raises(KeyError):
70+
list(ema("loss", decay=0.5)([{"other": 1.0}]))
71+
72+
73+
def test_ema_rejects_non_numeric_value() -> None:
74+
with pytest.raises(ValueError):
75+
list(ema("loss", decay=0.5)([{"loss": "abc"}]))
76+
77+
78+
def test_ema_is_pipe_stage() -> None:
79+
events = [{"loss": 10.0}, {"loss": 20.0}]
80+
81+
result = list(pipe(events, ema("loss", decay=0.5)))
82+
83+
assert "loss_ema" in result[0]
84+
assert result[0]["loss"] == 10.0

0 commit comments

Comments
 (0)