Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 42 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Metrics are discovered from what you log. With no layout given they all share on
plot = LivePlot(range(num_steps), "loss", "return | entropy", "lossD lossG | acc")
```

Each string is one panel. Names separated by spaces share the left y-axis; names after a `|` go on a right-hand y-axis. Anything you log that no string mentions gets a panel of its own. Every panel has a legend.
Each string is one panel. Names separated by spaces share the left y-axis; names after a `|` go on a right-hand y-axis. Anything you log that no string mentions gets a panel of its own. A panel with two or more curves has a legend; one with a single curve is named by its title.

For nested loops, create the plot once and wrap the inner loop with `plot(...)`; the count, and so the x-axis, continues across epochs and you get one tqdm bar per epoch, as with tqdm:

Expand Down Expand Up @@ -79,47 +79,49 @@ Interrupting the cell is safe. Jupyter sends its interrupt to every process the
| | |
|---|---|
| `total`, `initial`, `unit`, `unit_scale` | tqdm's arguments, with tqdm's meaning; they define the x-axis (see above) |
| `refresh_seconds` | minimum time between redraws (default 1.0). Points arriving in between are batched into the next frame. `0` redraws whenever new data arrives, as fast as rendering allows (roughly 0.15 s per frame at a few thousand points), and costs nothing while idle. |
| `refresh_seconds` | minimum time between redraws (default 0.2). Points arriving in between are batched into the next frame. `0` redraws whenever new data arrives, as fast as rendering allows (roughly 0.15 s per frame at a few thousand points), and costs nothing while idle. |
| `max_cols`, `rows`, `cols` | grid shape; `max_cols=None` gives a near-square grid |
| `progress`, `desc` | disable the bundled tqdm bars, or give the single-loop form's bar a description |
| `cell_size`, `dpi` | size of each panel in inches, and PNG resolution |
| `figsize`, `cell_size`, `dpi` | the whole figure's size in inches, as matplotlib means it, however many panels it ends up with; without it, each panel is `cell_size` (default `(5, 3.5)`). `dpi` is the PNG resolution. |
| `record` | `True` keeps every rendered frame in `plot.frames`; a path such as `"run.gif"` also writes an animated GIF at `finish()`. `plot.save_gif(path, speedup=1.0)` does it on demand. Works outside a notebook too. |

## Titles, labels, limits: matplotlib's names

Panels and axes are addressed and configured with the setters you already know from matplotlib, before or during the loop:
The plot is the figure and each panel's y-axes are matplotlib Axes, configured with matplotlib's own names and signatures, before or during the loop:

```python
plot = LivePlot(loader, "loss | acc", "lr")

plot["acc"].set_ylim(0, 1) # plot[metric] is the y-axis holding that metric (left or right)
plot["acc"].set_ylabel("test accuracy")
plot["loss"].axhline(0.1, label="target", color="red") # extra kwargs go to the matplotlib artist
plot["loss"].set_ylim(bottom=0) # one end fixed, the other follows the data
plot["acc"].set_ylabel("test accuracy", fontsize=9) # text kwargs, as in matplotlib
plot["lr"].set_yscale("log") # any matplotlib scale: "log", "symlog", "logit", ...

plot.panels[0].set_title("training") # a panel: set_title, set_xlabel, set_xlim, set_smooth, axvline
plot.panels[0].set_xlim(0, 10_000)
plot.panels[0].axvline(label="lr drop") # at the current x
plot.axes[0].set_title("training", loc="left") # plot.axes, like Figure.axes
plot.axes[0].set_xlim(0, 10_000)
plot.axes[0].set_xscale("log")

plot.set(xlabel="examples", smooth=0.9) # on the plot itself: every panel, like Axes.set(**kwargs)
plot.suptitle("run 3") # Figure.suptitle / supxlabel / supylabel
plot.set_all(xlabel="examples", smooth=0.9) # Axes.set on every panel, including ones created later
```

`plot[metric]` finds the right-hand axis too, so there is no separate spelling for it; `plot.panels[i].left` / `.right` name the two axes explicitly. Everything on an axis takes matplotlib's name and signature: `set_ylabel`, `set_ylim` (two arguments or a tuple), `set_yscale("log")`, `axhline`, `set(**kwargs)`, and the panel's own setters are reachable from it as they would be on an `Axes`. A setter called mid-run just triggers a re-layout on the next frame.
There is one `Axes` type, as in matplotlib: `plot.axes[i]` and `subplots` give a panel's main axis, `ax.twinx()` its right-hand one, and `plot[metric]` whichever of them holds that metric. A y-axis has its own curves, `set_ylabel`, `set_ylim`, `set_yscale` and `axhline`; the title, x-axis, `axvline`, legend and smoothing belong to the panel, so setting them on either twin sets them for both. Everything takes matplotlib's name and signature: `set_title`, `set_xlabel`, `set_ylabel` (with `fontdict`, `loc`, `pad` / `labelpad` and text kwargs), `set_xlim` / `set_ylim` (two arguments, a tuple, or one end: `bottom=`, `top=`, `left=`, `right=`, `ymin=`, ...), `set_xscale` / `set_yscale` (with the scale's kwargs), `axhline` / `axvline`, `legend(**kwargs)` and `set(**kwargs)`. Each call is first tried on a scratch matplotlib `Axes`, so a misspelt keyword raises matplotlib's own error on the line that made it, not later in the renderer. A setter called mid-run just triggers a re-layout on the next frame.

The dict form, `{"metrics": ["acc"], "ylim": (0, 1), ...}`, is still accepted in the layout list but the setters are the intended way.
The plot-level object is the figure, not every panel at once: `plot.set_title` does not exist (use `plot.suptitle`, or `plot.axes[0].set_title`). To set something on every panel, loop as you would over `fig.axes` -- `for ax in plot.axes: ax.set(...)` -- or use `plot.set_all(...)`, which also reaches panels a metric logged later creates.

`plot.figure()` returns a matplotlib Figure of the plot as it stands, built independently of the live renderer, for `fig.savefig(...)`, a title, or any other tweak.
`plot.savefig("run.png")` saves the plot as it stands, like `Figure.savefig`, and `plot.snapshot()` returns it as a matplotlib Figure to tweak or add to; both are built independently of the live renderer and work in or out of a notebook. Outside a notebook nothing is drawn live, and liveplot says so once rather than leaving you wondering; `plot.data` still collects everything.

`plot.log` accepts keywords, an explicit step (`plot.log(step, loss=...)`), or a dict (`plot.log(step, {"loss": ...})`). Values can be anything `float()` accepts, including one-element tensors. `plot.data` holds the full history as `{metric: (steps, values)}` and `plot.latest` the most recent value of each.
`plot.log` accepts keywords, an explicit step (`plot.log(step, loss=...)`), or a dict (`plot.log(step, {"loss": ...})`). Values can be anything `float()` accepts, including one-element tensors, which are detached first, so `plot.log(loss=loss)` is fine without `.item()`. `plot.data` holds the full history as `{metric: (steps, values)}` and `plot.latest` the most recent value of each.

## Reference lines

Matplotlib's `axhline` / `axvline`, on an axis, a panel, or the whole plot. Horizontal lines get a legend entry; extra kwargs go to the artist.
Matplotlib's `axhline(y=0, xmin=0, xmax=1, **kwargs)` and `axvline(x=0, ymin=0, ymax=1, **kwargs)`, on an axis or a panel. They are dashed (horizontal) or dotted (vertical) grey unless kwargs say otherwise, and as in matplotlib a line is in the legend only if you give it a `label`.

```python
plot["loss"].axhline(math.log(d_vocab), "uniform") # the level a curve should beat
plot["return"].axhline(500, "solved", color="green")
plot.axvline(label="lr drop") # every panel, at the current x
plot.panels[1].axvline(2000, "checkpoint", linestyle="-") # one panel, at a given x
plot["loss"].axhline(math.log(d_vocab), label="uniform") # the level a curve should beat
plot["return"].axhline(500, label="solved", color="green")
plot.axes[0].axvline(plot.step, label="lr drop") # at the current point of the run
plot.axes[1].axvline(2000, label="checkpoint", linestyle="-") # at a given x
```

## Images beside curves: `subplots`
Expand All @@ -130,7 +132,8 @@ every so often, in one figure and one output cell.

```python
plot, (ax_loss, ax_samples) = LivePlot.subplots(1, 2, total=epochs * len(loader), figsize=(11, 4))
ax_loss.plot("lossD", "lossG") # which metrics live on this axis
ax_loss.plot("lossD") # a metric's name where matplotlib takes data
ax_loss.plot("lossG", "--") # format strings and Line2D kwargs, as in matplotlib
ax_loss.twinx().plot("D(x)") # matplotlib's own spelling for a right-hand axis
ax_samples.set_title("generator samples")

Expand All @@ -142,14 +145,23 @@ for epoch in range(epochs):
plot.finish()
```

`ax.plot("lossD", "lossG")` names metrics rather than passing data, which is matplotlib's own
`ax.plot("lossD")` names a metric rather than passing data, which is matplotlib's own
`ax.plot("lossD", data=d)` form with the data source implicit -- the plot is the source, filled in
later by `log()`. `axes` follows matplotlib's squeeze rules: one panel for 1x1, a flat list for a
single row or column, a 2-d grid otherwise (`axes[0][1]` and `axes[0, 1]` both work, and
`axes.flat` walks it). `figsize` is the whole figure in inches, as matplotlib means it, and
later by `log()`. As in matplotlib it takes an optional format string (`"r--"`, `"o"`, `"C2:"`) and
Line2D kwargs (`label=`, `color=`, `linewidth=`, `alpha=`, `marker=`, ...), and returns a
one-element list of lines, so `(line,) = ax.plot("loss")` and later `line.set_color("k")` work;
`ax.lines` gives the same handles. One metric per call: matplotlib reads `ax.plot("a", "b")` as b
against a, so here it is an error that says so rather than two curves. `subplots` takes
matplotlib's arguments -- `sharex`, `sharey`, `squeeze`, `width_ratios`, `height_ratios`,
`subplot_kw`, `gridspec_kw`, `figsize` -- and `axes` is a numpy array of panels squeezed as
matplotlib does (one panel for 1x1, 1-d for a single row or column, 2-d otherwise), so
`axes[0, 1]`, `axes.flat` and `axes.flatten()` all work. `figsize` is the whole figure in inches, as matplotlib means it, and
`width_ratios` / `height_ratios` are matplotlib's too: `width_ratios=(1, 1.25)` gives a wide grid
of samples more room. `ax.legend(**kwargs)` goes to `Axes.legend`, so a crowded legend can move
off the curves: `ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.14), ncols=3)`.
of samples more room. `ax.legend(**kwargs)` goes to `Axes.legend`: it shows the legend on a
single-curve panel, or moves a crowded one off the curves:
`ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.14), ncols=3)`. A metric you log in a
`subplots` grid without an `ax.plot()` for it still gets drawn (on the first free curve panel), with
a warning, since that is usually a typo.

A training loop that already drives its own bar, one for the whole run rather than one per epoch,
keeps its shape: `plot.update()` and `plot.set_description(...)` are tqdm's manual-mode methods,
Expand Down Expand Up @@ -181,7 +193,8 @@ for step in ...:
### What `imshow` accepts

A batch is tiled into a grid for you: `rows=` or `cols=` alone infers the other, `griddim=(r, c)`
fixes both, padding with blanks or dropping the tail as needed.
fixes both, padding with blanks or dropping the tail as needed. Any other keyword (`interpolation=`, `aspect=`, ...) goes to
matplotlib's `imshow`; the default interpolation is `"nearest"`, so pixels stay crisp.

| input | read as |
|---|---|
Expand Down Expand Up @@ -214,10 +227,10 @@ go to black with no way to say otherwise.

## Smoothing and log axes

Per-step losses are noisy. `set_smooth(0.9)` draws each curve of a panel through wandb's default smoothing, the [time-weighted exponential moving average](https://docs.wandb.ai/models/app/features/panels/line-plot/smoothing), with the same 0 to 1 weight as wandb's smoothing slider and the raw values faded behind. On the plot it applies to every panel; `set_smooth(0)` turns it off. `set_yscale("log")` on an axis, a panel, or the plot gives log axes.
Per-step losses are noisy. `set_smooth(0.9)` on a panel draws each of its curves through wandb's default smoothing, the [time-weighted exponential moving average](https://docs.wandb.ai/models/app/features/panels/line-plot/smoothing), with the same 0 to 1 weight as wandb's smoothing slider and the raw values faded behind; `set_smooth(0)` turns it off. For every panel, pass `smooth=0.9` to `LivePlot` or call `plot.set_all(smooth=0.9)`. Smoothing is liveplot's own; matplotlib has no equivalent. `set_yscale("log")` on an axis or a panel gives log axes.

```python
plot.set_smooth(0.9)
plot.set_all(smooth=0.9)
plot["loss"].set_yscale("log")
```

Expand Down
12 changes: 8 additions & 4 deletions examples/dcgan_synthetic.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,15 @@ def train(self, **plot_kwargs) -> LivePlot:
self.plot, (ax_loss, self.ax_samples) = LivePlot.subplots(
1, 2, total=self.total_steps, figsize=(12, 4.5), **plot_kwargs
)
ax_loss.plot("lossD", "lossG")
ax_loss.plot("lossD")
ax_loss.plot("lossG")
ax_loss.set_smooth(0.6) # per-batch GAN losses are noisy: wandb's smoothing, raw values faded behind
ax_loss.axhline(math.log(4), "ln 4: lossD, D at chance") # where lossD starts, and where a perfect G ends
ax_loss.axhline(math.log(2), "ln 2: lossG, D at chance", linestyle=":") # ... and the same for lossG
ax_loss.twinx().plot("D(x)", "D(G(z))").set_ylim(0, 1)
ax_loss.axhline(math.log(4), label="ln 4: lossD, D at chance") # where lossD starts, and where a perfect G ends
ax_loss.axhline(math.log(2), label="ln 2: lossG, D at chance", linestyle=":") # ... and the same for lossG
ax_d = ax_loss.twinx()
ax_d.plot("D(x)")
ax_d.plot("D(G(z))")
ax_d.set_ylim(0, 1)
ax_loss.set_title("losses (left), discriminator outputs (right)")
ax_loss.legend(loc="upper center", bbox_to_anchor=(0.5, -0.14), ncols=3) # under the panel, off the curves
self.ax_samples.set_title("netG(fixed_noise)")
Expand Down
19 changes: 11 additions & 8 deletions examples/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ def slow(seconds=0.01):

# %%
# 1. The one-liner: wrap the range like tqdm, log keyword arguments. Metrics are discovered from
# what you log and, with no layout given, share a single panel with a legend. A tqdm bar sits
# what you log and, with no layout given, share a single panel (with a legend, once there are
# two or more). A tqdm bar sits
# under the plot with the latest values as its postfix.

if MAIN:
Expand Down Expand Up @@ -108,8 +109,8 @@ def train_classifier(steps=600, batch_size=64, lr=0.5, eval_every=50, seed=0, pa
plot = LivePlot("loss", "acc", total=epochs * len(loader))
plot["acc"].set_ylim(0, 1) # plot[metric] is the y-axis holding that metric
plot["acc"].set_ylabel("test accuracy")
plot.panels[0].set_title("training loss")
plot.set(xlabel="batches") # every panel, like Axes.set(**kwargs)
plot.axes[0].set_title("training loss") # plot.axes, like Figure.axes
plot.set_all(xlabel="batches") # every panel, including any created later
for epoch in range(epochs):
for batch in plot(loader, desc=f"epoch {epoch}"):
plot.log(loss=math.exp(-plot.n / 60) + 0.05 * random.random())
Expand All @@ -131,8 +132,9 @@ def train_classifier(steps=600, batch_size=64, lr=0.5, eval_every=50, seed=0, pa
if step >= 200:
plot.log(step, lr=1e-3 * (400 - step) / 200)
if step == 200:
plot.axvline(label="lr decay starts") # a vertical line on every panel, at the current x
plot["acc"].axhline(0.9, "target") # a horizontal reference line on acc's axis
for ax in plot.axes: # a vertical line on every panel, at the current x
ax.axvline(plot.step, label="lr decay starts")
plot["acc"].axhline(0.9, label="target") # a horizontal reference line on acc's axis
slow()

# %%
Expand Down Expand Up @@ -182,8 +184,8 @@ def train_classifier(steps=600, batch_size=64, lr=0.5, eval_every=50, seed=0, pa

# %%
# 9. Images beside curves: LivePlot.subplots mirrors plt.subplots, and a panel can hold a picture
# instead of lines. `ax.plot("name", ...)` says which metrics live on an axis (matplotlib spells
# the same idea `ax.plot("name", data=d)`); `ax.imshow(tensor)` draws a batch as a grid and
# instead of lines. `ax.plot("name")` puts a metric on an axis (matplotlib's `ax.plot("name",
# data=d)`, with log() as the data), one per call; `ax.imshow(tensor)` draws a batch as a grid and
# replaces it on every call. This is the shape a GAN training loop wants: loss curves updating
# every step, generated samples every so often, in one figure.
#
Expand All @@ -206,7 +208,8 @@ def fake_generator(step, rng, n=8, size=24):
if MAIN:
rng = np.random.default_rng(0)
plot, (ax_loss, ax_samples) = LivePlot.subplots(1, 2, total=400, figsize=(11, 4))
ax_loss.plot("lossD", "lossG")
ax_loss.plot("lossD")
ax_loss.plot("lossG", "--") # a format string, as in matplotlib
ax_loss.twinx().plot("D(x)") # matplotlib's own spelling for a right-hand axis
ax_samples.set_title("generator samples")

Expand Down
Loading
Loading