Live training curves in Jupyter, Colab and the VS Code / Cursor interactive window, with a tqdm bar underneath, at (almost) no cost to the training loop.
Try it in Colab with the badge above: that notebook is examples/demo.py, a cell-by-cell tour of the features, which CI converts with jupytext and publishes to the demo branch on every push to main.
The GIF is cell 2 of examples/demo.py, a tour of the features in a cell-separated file: open it in the VS Code / Cursor interactive window and run it cell by cell. That cell trains a tiny numpy classifier, logging the training loss every step and the eval loss and accuracy every 50 steps, laid out as "loss | eval_loss eval_acc". The GIF itself was recorded by the library: examples/make_gif.py runs the same loop as a plain script with record="docs/demo.gif".
from liveplot import LivePlot
plot = LivePlot(range(num_steps)) # a tqdm bar + a live plot
for step in plot:
loss, acc = train_step()
plot.log(loss=loss, acc=acc) # step is implicit; the bar's postfix shows the latest valuesMetrics are discovered from what you log. With no layout given they all share one panel, with a legend. To split them up, give panel strings:
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.
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:
plot = LivePlot("loss", "acc", total=epochs * len(loader))
for epoch in range(epochs):
for imgs, labels in plot(loader, desc=f"epoch {epoch}"): # kwargs go to tqdm
plot.log(loss=train_step(imgs, labels))
plot.log(acc=evaluate()) # metrics can have different cadences
plot.finish() # or wrap the whole thing in `with LivePlot(...) as plot:`If you'd rather supply the x values yourself, use the context manager and pass the step to log:
with LivePlot("loss", "acc", total=num_steps) as plot:
for step in range(num_steps):
plot.log(step, loss=train_step())Already have a tqdm bar? Pass it as the iterable and it is reused instead of wrapped: LivePlot(tqdm(loader), "loss").
Nothing special is needed. Every metric keeps its own list of (x, value) points, and each log call stamps its metrics with the current x. A metric logged every step gets a point per step; one logged every 50 steps, or once per epoch after the inner loop, gets a point wherever the count was at the time and is drawn as a line through those points. So in "loss | eval_loss eval_acc" the left axis fills in continuously while the right axis grows a point every 50 steps, all against the same x.
It follows tqdm: the plot counts items consumed and never looks at their values.
x = initial + n * unit_scale
n is 0 inside the loop body for the first item, like for step in range(N). initial shifts the start (initial=1 for 1-based, initial=10 to plot range(10, 20) at its values). unit and unit_scale relabel and rescale it: unit="examples", unit_scale=batch_size plots against examples seen, and the tqdm bar shows the same numbers. total, in items like tqdm's, fixes the x range; the single-loop form takes it from len(iterable). An explicit plot.log(step, ...) uses that x for that call only, like wandb's step=.
pip install "liveplot[notebook] @ git+https://github.com/ARENA-education/liveplot.git"
matplotlib is the only hard requirement (numpy and pillow, which the smoother and save_gif use, come with it). The notebook extra adds ipython for the live display and tqdm for the bar; any notebook already has both, so plain pip install git+... is fine there, and without them the plot silently just collects plot.data.
The training thread only appends numbers (about 40 µs per log). A separate render process owns the matplotlib figure, redraws it at most once per refresh_seconds (default 1; 0 means on every arrival), and sends back PNG bytes that get swapped into a fixed output cell. The output is a plain image, so it behaves identically in Jupyter, Colab, VS Code and Cursor: no widgets, no JavaScript, no CDN.
Interrupting the cell is safe. Jupyter sends its interrupt to every process the kernel started; the render process ignores it, so you get a frozen plot with plot.data intact. A plot that is dropped without finish() shuts its process down when garbage collected, and the process exits by itself if the notebook kernel dies.
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. |
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 |
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. |
Panels and axes are addressed and configured with the setters you already know from matplotlib, before or during the loop:
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.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.set(xlabel="examples", smooth=0.9) # on the plot itself: every panel, like Axes.set(**kwargs)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.
The dict form, {"metrics": ["acc"], "ylim": (0, 1), ...}, is still accepted in the layout list but the setters are the intended way.
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.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.
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.
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 xPer-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, 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.
plot.set_smooth(0.9)
plot["loss"].set_yscale("log")The per-panel label/limit options and the grid-shape rule are adapted from Tyler Lum's live_plotter (MIT); see THIRD_PARTY_LICENSES.md.
