Skip to content

Follow matplotlib's API, and make liveplot easier to use - #15

Open
davidquarel wants to merge 2 commits into
mainfrom
mpl-compat
Open

davidquarel wants to merge 2 commits into
mainfrom
mpl-compat

Conversation

@davidquarel

@davidquarel davidquarel commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Two commits. The first makes liveplot follow matplotlib's API as closely as it can while the data comes from log(). The second fixes the usability problems found while doing that. Nothing depends on liveplot yet, so the breaking changes are made now rather than kept for compatibility.

1. matplotlib's API (0b8920a)

  • ax.plot("loss", fmt, **Line2D_kwargs)
    • Takes one metric per call and returns [line], and the line has Line2D's setters: (line,) = ax.plot("loss"); line.set_color("k").
    • Format strings work ("r--", "o", "C2:", "tab:orange"), and so does ax.lines.
    • ax.plot("a", "b") used to draw two curves, but matplotlib reads it as b against a. It is now an error that says so and shows the one-call-per-metric spelling.
  • axhline(y=0, xmin=0, xmax=1, **kw) / axvline(x=0, ymin=0, ymax=1, **kw)
    • These are matplotlib's signatures. label is a keyword; before, it was the second positional argument, which matplotlib reads as xmin.
    • A line is in the legend only if it has a label, and labels starting with _ are left out.
    • axvline labels are now legend entries instead of text drawn along the line.
  • Limits: one-sided limits work (set_ylim(bottom=0), where the other end follows the data), as do the ymin/ymax/xmin/xmax aliases.
  • Scales and text: set_xscale is new, and any matplotlib scale can be used, with its kwargs. Titles and labels take fontdict, loc, pad/labelpad and text kwargs.
  • subplots: takes matplotlib's arguments (sharex, sharey, subplot_kw, gridspec_kw, width_ratios, height_ratios, figsize, squeeze) and returns a numpy array of axes, so axes.flatten(), axes[0, 1] and axes.flat all work.
  • The plot is the figure:
    • New: plot.axes, suptitle, supxlabel, supylabel.
    • The plot-level setters that silently changed every panel are removed. plot.set_all(...) does the same thing explicitly, and also reaches panels created later.
    • plot.axhline(metric=) and plot.axvline() are removed.
  • imshow: other kwargs (interpolation, aspect, …) are passed to matplotlib.
  • Checked where you call them: every setter is first tried on a scratch matplotlib Axes, so a mistake such as colr= or set_yscale("sqrt") raises matplotlib's own error on the line that caused it. Before, it killed the render process.

2. Easier to use (1b4dd60)

  • One Axes type:
    • Panel and _Axis are merged. plot.axes[i], the axes from subplots, ax.twinx() and plot["acc"] are now all Axes.
    • As with matplotlib twins, each side has its own curves, y-label, y-limits, y-scale and axhline, and the two share the panel's title, x-axis, axvline, legend and smoothing.
  • Layouts are strings only. The dict form ({"metrics": [...], "ylim": ...}) is gone; the setters cover everything it did.
  • Saving: plot.savefig(path, **kw) works like Figure.savefig, and plot.figure() is renamed plot.snapshot().
  • Outside a notebook, liveplot now says once that nothing is drawn live, instead of staying silent.
  • log() detaches tensors, so plot.log(loss=loss) needs no .item() and torch no longer warns.
  • Legends appear only with two or more entries, or when ax.legend() asks for one. A single-curve panel is already named by its title.
  • Typos: in a subplots grid, a logged metric with no ax.plot() for it triggers a warning, since it is usually a typo. Metrics that are not in a layout string still get their own panel with no warning, as documented.
  • figsize= is accepted by the constructor too, and means the whole figure however many panels appear.
  • refresh_seconds now defaults to 0.2, the same one-line change as on the stranded refresh-default branch.

Not included

Testing

  • 76 tests pass locally (pytest tests, matplotlib 3.11.2).
  • New tests cover:
    • the fmt parser and Line2D styling;
    • the two-names error;
    • one-sided limits, as rendered;
    • text and scale kwargs, and the early errors;
    • set_all, suptitle and supxlabel;
    • the single Axes type and twins;
    • the notebook notice and savefig;
    • tensor detaching;
    • the typo warning;
    • figsize on the constructor;
    • sharex, gridspec_kw and subplot_kw;
    • imshow kwargs.
  • examples/demo.py and examples/dcgan_synthetic.py run to completion as scripts.
  • In a real Jupyter kernel with the render process running, frames are drawn and the process exits cleanly. The saved figure was checked visually for legend placement, figsize, pinned limits and the twin-axis label.
  • There is one pre-existing issue, unchanged here: under -W error, test_record_and_save_gif reports a ResourceWarning for a file the test leaves open. It does the same on main.

🤖 Generated with Claude Code


Verification (2026-10-02)

Environment: Python 3.12.3, matplotlib 3.11.2, numpy 2.5.3, IPython 9.17.1, torch 2.14.1+cu130 (CPU only), MPLBACKEND=Agg. Base main @ f49ee1b, head pr15 @ 1b4dd60 (2 commits: 0b8920a, 1b4dd60). Fresh clone in the scratchpad. Scripts are in scratchpad/libs/scripts/liveplot-15/ (claims.py, twonames.py, visual.py, axvline_default.py, hcolor.py).

This is an API redesign, not a bug fix. "Bug on base" below means "the base lacks the claimed matplotlib-compatible behaviour". Each claim was probed with the same script on base and head (claims.py; the exceptions it prints are real, not hidden).

Bug on base

On base, liveplot's API departs from matplotlib's wherever the PR says it does:

Claim Base (main) Head (PR)
ax.plot("loss", "r--") with a fmt string "r--" becomes a second metric, so two curves are drawn one red dashed curve; returns [<liveplot line 'loss'>]
(line,) = ax.plot("loss"); line.set_color("k") TypeError: cannot unpack non-iterable _Axis object color k in the rendered figure
ax.plot("c", color="tab:orange", alpha=0.5), plus "C2:" and "o" TypeError: Panel.plot() got an unexpected keyword argument 'color' C2/:, marker o, tab:orange/alpha 0.5 all appear in the render
ax.plot("lossD", "lossG") two curves TypeError: ... one metric per call. matplotlib reads two names as x and y ...
axhline signature (y, label=None, **kwargs) (y=0, xmin=0, xmax=1, **kwargs); axhline(0.7, 0.25, 0.75) draws x 0.25..0.75
unlabelled / _hidden axhline in the legend legend ['loss','acc','target','0.3','_hidden'] legend ['loss','acc','target']
axvline(3, label="lr drop") text drawn on the line, not in the legend legend entry lr drop, no text
set_ylim(bottom=0) AssertionError: give both limits rendered ylim (0.0, 14.45), so the top follows the data
set_ylim(ymax=20) TypeError ylim (4.55, 20.0)
set_xscale("log") AttributeError (no such method) xscale log
set_yscale("symlog", linthresh=0.01) TypeError (no kwargs; only linear and log) symlog, linthresh 0.01
set_yscale("sqrt") AssertionError: yscale must be 'linear' or 'log' (already at the call site) ValueError: 'sqrt' is not a valid value for scale. (matplotlib's own, at the call site)
ax.plot("loss", colr="red") TypeError from liveplot's signature AttributeError: Line2D.set() got an unexpected keyword argument 'colr', at the call site
set_title("hi", fontsize=20, loc="left") / set_ylabel(..., labelpad=, color=) TypeError left title, size 20, red ylabel
subplots(2, 2) return type _PanelGrid (no .flatten()) numpy.ndarray of Axes, shape (2, 2)
subplots(2, 1, sharex=True, height_ratios=[2, 1]) TypeError works
suptitle / supxlabel AttributeError in the figure
set_all(yscale="log", xlabel=...) before any panel exists AttributeError applies to the panel created later
imshow(..., interpolation="bilinear") TypeError interpolation bilinear
plot.savefig(path, dpi=50) AttributeError writes a 6436-byte PNG
plot.log(loss=<tensor requiring grad>) UserWarning: Converting a tensor with requires_grad=True to a scalar ... no warning; values stored as float
legend on a single-curve panel ['loss'] none; ax.legend() brings it back as ['loss']
subplots() grid, logging a metric no ax.plot() named (lsos) silent UserWarning: ... logged 'lsos', which no ax.plot() put on an axis ... A typo?
LivePlot("loss", figsize=(8,3)), with 3 panels appearing TypeError snapshot().get_size_inches() == (8, 3)
refresh_seconds default 1.0 0.2
outside a notebook silent UserWarning: LivePlot: not in a notebook, so nothing is drawn live ..., once per process (emitted on the first probe, not repeated later)
claims.py on base (trimmed)
[plot(name, fmt)] -> returned=<liveplot.liveplot._Axis object at 0x...>; metrics=['loss', 'r--']; drawn lines=[('loss', (0.12,0.47,0.71), '-'), ('r--', (1.0,0.50,0.05), '-')]
[(line,)=ax.plot; line.set_color] RAISES TypeError: cannot unpack non-iterable _Axis object
[fmt C2: / o / color kw] RAISES TypeError: Panel.plot() got an unexpected keyword argument 'color'
[ax.plot('a','b')] -> no error; metrics=['a', 'b']
    axhline sig: (y, label=None, **kwargs)
[axhline label kw / unlabelled / _label] -> legend=['loss', 'acc', 'target', '0.3', '_hidden']; n_lines=4
[axhline(0.7, 0.25, 0.75) positional] RAISES TypeError: _Axis.axhline() takes from 2 to 3 positional arguments but 4 were given
[axvline label] -> legend=['loss', 'acc']; texts=['lr drop']
[set_ylim(bottom=0)] RAISES AssertionError: give both limits, e.g. set_ylim(0, 1)
[set_ylim(ymax=20)] RAISES TypeError: _Axis.set_ylim() got an unexpected keyword argument 'ymax'
[set_xscale('log')] RAISES AttributeError: '_Axis' object has no attribute 'set_xscale'
[set_yscale('symlog', linthresh=0.01)] RAISES TypeError: _Axis.set_yscale() got an unexpected keyword argument 'linthresh'
[set_yscale('sqrt') bad scale] RAISES AssertionError: yscale must be 'linear' or 'log', got 'sqrt'
[text kwargs on set_title / set_ylabel] RAISES TypeError: _Axis.set_title() got an unexpected keyword argument 'fontsize'
[subplots(2,2) return] -> type=_PanelGrid shape=(2, 2) has flatten=False elt=Panel
[subplots(sharex, height_ratios)] RAISES TypeError: LivePlot.__init__() got an unexpected keyword argument 'sharex'
[suptitle / supxlabel] RAISES AttributeError: 'LivePlot' object has no attribute 'suptitle'
[set_all before panels exist] RAISES AttributeError: 'LivePlot' object has no attribute 'set_all'
[imshow(interpolation=)] RAISES TypeError: Panel.imshow() got an unexpected keyword argument 'interpolation'
[plot.savefig] RAISES AttributeError: 'LivePlot' object has no attribute 'savefig'
[log(loss=<grad tensor>)] -> stored=[6.0, 4.0] types=['float', 'float']   WARNINGS=['UserWarning: Converting a tensor with requires_grad=True to a scalar may lead to unexpected behavior.\nConsider using tensor.detach() ']
[legend with one curve] -> single-curve legend=['loss']; after ax.legend()=['loss']
[subplots grid: unplotted metric 'lsos'] -> logged
[LivePlot(figsize=(8,3)) with 3 panels] RAISES TypeError: LivePlot.__init__() got an unexpected keyword argument 'figsize'
[refresh_seconds default] -> 1.0

Fixed on this PR

Every claimed behaviour in the table above works on head. One-sided limits and the axvline legend entry were also checked in the rendered PNG: out_head/one_sided_ylim.png has y from 0 up to the data. In visual_head.png, "lr drop" is a legend entry, where visual_base.png writes it as rotated text on both panels. Head also draws the axvline only on the panel it was called on; base drew a plot-wide line, so it appeared on the lr panel too.

claims.py on head (trimmed)
[plot(name, fmt)] -> returned=[<liveplot line 'loss'>]; metrics=['loss']; drawn lines=[('loss', 'r', '--')]   WARNINGS=['UserWarning: LivePlot: not in a notebook, so nothing is drawn live. Every value is kept in plot.data, and plot.savefig("run.png") sav']
[(line,)=ax.plot; line.set_color] -> color after set_color('k') = k; ax.lines=[<liveplot line 'loss'>]
[fmt C2: / o / color kw] -> [('a', 'C2', ':', 'None', None), ('b', (1.0,0.50,0.05), 'None', 'o', None), ('c', 'tab:orange', '-', 'None', 0.5)]
    axhline sig: (y=0, xmin=0, xmax=1, **kwargs)
[axhline label kw / unlabelled / _label] -> legend=['loss', 'acc', 'target']; n_lines=4
[axhline(0.7, 0.25, 0.75) positional] -> last line xdata=[0.25, 0.75] ydata=[0.7, 0.7] legend=None
[axvline label] -> legend=['loss', 'acc', 'lr drop']; texts=[]
[set_ylim(bottom=0)] -> ylim=(0.0, 14.45)
[set_ylim(ymax=20)] -> ylim=(4.55, 20.0)
[set_xscale('log')] -> xscale=log
[set_yscale('symlog', linthresh=0.01)] -> yscale=symlog linthresh=0.01
[set_yscale('sqrt') bad scale] RAISES ValueError: 'sqrt' is not a valid value for scale.
[plot(colr='red') typo kwarg] RAISES AttributeError: Line2D.set() got an unexpected keyword argument 'colr'
[text kwargs on set_title / set_ylabel] -> left title='hi' size=20.0 ylabel color=red
[subplots(2,2) return] -> type=ndarray shape=(2, 2) has flatten=True elt=Axes
[subplots(sharex, height_ratios)] -> ok type=ndarray
[suptitle / supxlabel] -> suptitle='run 1' supxlabel='examples'
[set_all before panels exist] -> yscale=log xlabel='examples'
[plot.set_yscale (removed?)] RAISES AttributeError: 'LivePlot' object has no attribute 'set_yscale'
[plot.axhline(metric=) (removed?)] RAISES AttributeError: 'LivePlot' object has no attribute 'axhline'
[plot.panels / plot.axes] -> has panels=False has axes=True
[imshow(interpolation=)] -> interp=bilinear
[dict layout] RAISES TypeError: panels are given as strings, like "loss | acc", got dict: {'metrics': ['acc'], 'ylim': (0, 1)}. Titles, limits and labels go on the axes afterwards: plot["acc"].set_ylim(0, 1)
[plot.savefig] -> wrote 6436 bytes
[plot.figure exists] -> False
[plot.snapshot exists] -> True
[log(loss=<grad tensor>)] -> stored=[6.0, 4.0] types=['float', 'float']
[legend with one curve] -> single-curve legend=None; after ax.legend()=['loss']
[subplots grid: unplotted metric 'lsos'] -> logged   WARNINGS=["UserWarning: LivePlot: logged 'lsos', which no ax.plot() put on an axis, so it joins Axes(0: loss). A typo? Otherwise call ax.plot() "]
[layout string: undeclared metric] -> no-subplots layout: 2 panels
[LivePlot(figsize=(8,3)) with 3 panels] -> size=(np.float64(8.0), np.float64(3.0))
[refresh_seconds default] -> 0.2

Tests

Run Result
base tests on base code 65 passed
head tests on head code 76 passed (matches the PR's "76 tests pass")
base tests on head code (breaking-change check) 3 modules fail to import (Panel, _Axis, _normalise_axhlines removed); of the rest, 8 failed / 34 passed. Every failure is a removed or renamed API: the dict layout form, plot.figure(), plot-level set_title, the single-curve legend, and a _layout index
CI (gh pr checks) build pass, pytest pass
-W error -k save_gif fails with the same ResourceWarning: unclosed file ... run.gif on both base and head, so the PR's "pre-existing issue" note is accurate
examples/demo.py, examples/dcgan_synthetic.py, examples/make_gif.py on head all finish (57 frames -> dcgan_synthetic.gif; make_gif final {'loss': 0.114, ...})

The test diffs are API respellings (figure() -> snapshot(), dicts -> setters, panels -> axes), plus new assertions. No assertion was loosened to make a test pass. One test was removed, test_axvline_skips_image_panels, which covered plot-wide axvlines; those no longer exist because axvline is per-panel now. test_normalise_axhlines was replaced by a test that the dict form raises.

Relevance to ARENA

The changes break the public API, but no ARENA code uses liveplot today, so nothing in ARENA breaks or needs updating when this merges.

  • git grep -il liveplot on davidquarel/ARENA_3.0 tl3.9-port, fetched fresh at 735a9a5a9 (equal to the local worktree HEAD): 0 files.
  • An all-branch git grep over the ARENA_3.0 clone's 2934 local and remote-tracking refs did not finish: it timed out twice, so it is not evidence either way. Only tl3.9-port, plus the checked-out files of every local worktree (next item), were actually searched.
  • A filesystem grep of the whole local ARENA_3.0 tree, all worktrees included, found only worktrees/matt-gmg/.../master_2_6.py. There, liveplot is a local variable holding the in-repo plotly helper LiveSubplots, not this library; there is no import liveplot.
  • gh search code liveplot --owner ARENA-education returned no hits outside the liveplot repo itself.

So the PR's claim that "nothing depends on liveplot yet" checks out. The change is independent of the TL3.9 / transformers-5 path.

Notes

  • Mergeability: MERGEABLE, and CI is green.

  • Partly overclaimed: the "two names" error. ax.plot("a", "b") raises only when the second name is not a valid matplotlib fmt string. If the second name is valid fmt ("b", "r", "o", "k", "g--", ...), it is silently read as a style:

    ax.plot('lossD', 'lossG') RAISES TypeError: ax.plot('lossD', 'lossG'): one metric per call. ...
    ax.plot('a', 'b')    -> no error; 'a' drawn in color 'b' (blue); 'b' only appears later via the unplotted-metric fallback (with the typo warning)
    ax.plot('loss', 'r') -> no error; 'loss' drawn red
    

    matplotlib has the same ambiguity, so this is defensible. Metric names that collide with fmt strings are rare, and the typo warning partly catches it. The body's wording ("It is now an error") is a little too strong, though.

  • A silent semantic change the body doesn't call out: axvline() with no x. On base, no x meant the current step, at the time of the call. On head it means x=0, matplotlib's default:

    base: stored axvlines: [{'x': 7.0, 'label': 'now'}] | p.step = 7
    head: stored axvlines: [{'label': 'now', 'x': 0.0}] | p.step = 7
    

    Old code like ax.axvline(label="lr drop") still runs, but now draws at the origin; the new spelling is ax.axvline(plot.step, label=...), which the tests and README use. Worth a sentence in the body.

  • Removals the body doesn't list explicitly:

    • plot.panels: replaced by plot.axes; the body only says plot.axes is "new".
    • plot.set(...): removed along with the broadcast setters; set_all replaces it.
    • Panel.left / Panel.right: gone; use ax and ax.twinx().
    • The _PanelGrid type: replaced by an ndarray.

    "Panel and _Axis are merged" and "plot-level setters ... are removed" cover these in spirit.

  • Error location: "Before, it killed the render process" holds for the kwargs that base passed through to matplotlib. For set_yscale("sqrt") specifically, base already raised at the call site (its own AssertionError). What head adds there is matplotlib's own error message.

  • Docs: README and examples/ on head use only the new API; grep found no stale figure(), dict-layout, plot.axhline or .panels uses. The images under docs/ (gifs) were not regenerated or checked.

  • Not tested: live drawing in a real Jupyter or Colab kernel; process mode was exercised only through the test suite's fake display handle.

Upstream status (2026-10-02)

  • No upstream. ARENA-education/liveplot has fork=false and no parent or source. ARENA created it on 2026-09-16, and it has 0 forks. Parts are adapted from tylerlum/live_plotter (MIT, credited in THIRD_PARTY_LICENSES.md), but it isn't a fork of it. The PyPI name liveplot (0.1.2, Philip Reinhold) is an unrelated project.
  • Recommendation: keep it. It's ARENA's own library, so there's nothing to upstream.

davidquarel and others added 2 commits September 29, 2026 18:22
…ubplots

- ax.plot("loss", fmt, **Line2D kwargs): one metric per call, returns [line] with
  Line2D setters; two names raise, since matplotlib reads them as x and y
- axhline(y=0, xmin, xmax, **kw) / axvline(x=0, ymin, ymax, **kw): label is a
  keyword, and only labelled lines (not "_"-prefixed) go in the legend
- one-sided limits (set_ylim(bottom=0)), ymin/ymax/xmin/xmax aliases
- set_xscale; any matplotlib scale, with scale kwargs; text kwargs on titles/labels
- the plot is the figure: plot.axes, suptitle/supxlabel/supylabel; broadcast
  setters replaced by set_all(), plot-level axhline/axvline removed
- subplots: sharex/sharey, subplot_kw, gridspec_kw; returns a numpy array of panels
- imshow passes interpolation/aspect/... to matplotlib
- every setter is tried on a scratch Axes first, so mistakes raise matplotlib's
  own error at the call site instead of killing the renderer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s, fewer silent failures

- one Axes class: plot.axes[i], subplots' axes, ax.twinx() and plot["acc"] are all
  Axes; twins keep their own y-settings and share the panel's (Panel/_Axis merged)
- panels are strings only; the dict layout form is gone (the setters cover it)
- plot.savefig(path, **kw) like Figure.savefig; figure() renamed snapshot()
- outside a notebook, say once that nothing is drawn live
- log() detaches tensors, so plot.log(loss=loss) needs no .item() and no torch warning
- a legend only with two or more entries, or when ax.legend() asks for one
- in a subplots() grid, warn when a logged metric has no ax.plot() (likely a typo)
- figsize= on the constructor too: the whole figure, however many panels appear
- refresh_seconds default 1.0 -> 0.2 (as on the stranded refresh-default branch)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant