From 0b8920acfbae7d91cc72178fdd243c894431765d Mon Sep 17 00:00:00 2001 From: David Quarel Date: Tue, 29 Sep 2026 18:22:25 +0000 Subject: [PATCH 1/2] Follow matplotlib's API: plot, axhline/axvline, limits, the figure, subplots - 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 --- README.md | 54 ++- examples/dcgan_synthetic.py | 12 +- examples/demo.py | 16 +- liveplot/liveplot.py | 800 +++++++++++++++++++++------------- tests/test_axes_api.py | 187 ++++++-- tests/test_figure_export.py | 7 +- tests/test_liveplot.py | 2 +- tests/test_reference_lines.py | 39 +- tests/test_robustness.py | 12 +- tests/test_subplots.py | 67 ++- 10 files changed, 771 insertions(+), 425 deletions(-) diff --git a/README.md b/README.md index 10300fd..a0a4d03 100644 --- a/README.md +++ b/README.md @@ -87,23 +87,27 @@ Interrupting the cell is safe. Jupyter sends its interrupt to every process the ## 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 its panels are the 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) # Panel.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. +`plot[metric]` finds the right-hand axis too, so there is no separate spelling for it; `plot.axes[i].left` / `.right` name the two axes explicitly. 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)`; the panel's setters are reachable from an axis as they would be on an `Axes`. 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 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. The dict form, `{"metrics": ["acc"], "ylim": (0, 1), ...}`, is still accepted in the layout list but the setters are the intended way. @@ -113,13 +117,13 @@ The dict form, `{"metrics": ["acc"], "ylim": (0, 1), ...}`, is still accepted in ## 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` @@ -130,7 +134,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") @@ -142,11 +147,17 @@ 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)`. @@ -181,7 +192,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 | |---|---| @@ -214,10 +226,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") ``` diff --git a/examples/dcgan_synthetic.py b/examples/dcgan_synthetic.py index 2afd3f0..cd746bb 100644 --- a/examples/dcgan_synthetic.py +++ b/examples/dcgan_synthetic.py @@ -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)") diff --git a/examples/demo.py b/examples/demo.py index 871f222..399595d 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -108,8 +108,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()) @@ -131,8 +131,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() # %% @@ -182,8 +183,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. # @@ -206,7 +207,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") diff --git a/liveplot/liveplot.py b/liveplot/liveplot.py index 8da4554..d3738af 100644 --- a/liveplot/liveplot.py +++ b/liveplot/liveplot.py @@ -46,34 +46,45 @@ {"metrics": ["acc"], "ylim": (0, 1), "ylabel": "test accuracy", "xlabel": "epoch"} -(the dict form; the setters below are the nicer way). Panels and axes are addressed -and configured with matplotlib's own names, before or during the loop: +(the dict form; the setters below are the nicer way). The plot is the figure and its +panels are the Axes, configured with matplotlib's own names and signatures, before +or during the loop: plot["acc"].set_ylim(0, 1) # the y-axis holding a metric (left or right) - plot["acc"].set_ylabel("test accuracy") - plot["loss"].axhline(0.1, label="target", color="red") - plot.panels[0].set_title("training") # a panel: title, xlabel, xlim, axvline, smooth - plot.panels[0].right.set_yscale("log") # .left / .right are the panel's y-axes - plot.set(xlabel="examples", smooth=0.9) # on the plot: every panel, like Axes.set - -Available on axes: set_ylabel, set_ylim, set_yscale, axhline, set(**kw); on panels: -set_title, set_xlabel, set_xlim, set_smooth, axvline, set(**kw), plus the left -axis's setters; on the plot: all of these for every panel, `axhline` (every left -axis, or `metric=` for one) and `axvline` (every panel). Extra kwargs on -`axhline` / `axvline` go to the matplotlib artist. Smoothing is wandb's + plot["loss"].set_ylim(bottom=0) # one end fixed, the other follows the data + plot["acc"].set_ylabel("test accuracy", fontsize=9) + plot["loss"].axhline(0.1, color="red", label="target") # a label puts it in the legend + plot.axes[0].set_title("training") # plot.axes, like Figure.axes + plot.axes[0].axvline(plot.step, label="lr drop") + plot.axes[0].right.set_yscale("log") # .left / .right are the panel's y-axes + plot.suptitle("run 3") # Figure.suptitle / supxlabel / supylabel + plot.set_all(xlabel="examples", smooth=0.9) # every panel, including ones made later + +Available on an axis: plot, set_ylabel, set_ylim, set_yscale, axhline, set(**kw), +lines; on a panel: set_title, set_xlabel, set_xlim, set_xscale, axvline, legend, +twinx, imshow, set_smooth, set(**kw), plus the left axis's setters. Arguments are +matplotlib's (text kwargs, scale kwargs, one-sided limits, Line2D kwargs on +reference lines), and each call is tried on a scratch matplotlib Axes first, so a +mistake raises matplotlib's own error where it was made. Smoothing is wandb's time-weighted EMA with the same 0 to 1 weight (`smooth=0.9`), raw values faded behind. -Images beside curves: `LivePlot.subplots` mirrors `plt.subplots`, and a panel can +Images beside curves: `LivePlot.subplots` mirrors `plt.subplots` (numpy array of +panels, sharex / sharey, width_ratios, gridspec_kw, subplot_kw), and a panel can hold a picture instead of lines -- loss curves updating every step next to samples from a generator, in one figure: plot, (ax_loss, ax_samples) = LivePlot.subplots(1, 2, total=n_steps, 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", "--", label="generator") # fmt strings and Line2D kwargs, as in matplotlib ax_loss.twinx().plot("D(x)") # matplotlib's spelling for a right-hand axis ... ax_samples.imshow(netG(noise), rows=2, vmin=-1, vmax=1) # replaces the last one +`ax.plot("loss")` is matplotlib's `ax.plot("loss", data=d)` with the data supplied +later by `log()`; like matplotlib it takes one series per call and returns `[line]` +(two names would mean x and y there, so they are an error here). + `plot.imshow(x)` does the same on a plot of its own, making the panel on first use. A batch is tiled for you (`rows` / `cols` / `griddim=(r, c)`, padding or dropping to fit, with make_grid's 2-pixel gaps); (H, W), (C, H, W), (H, W, C), (B, H, W), (B, C, H, W) and (B, H, W, C) are @@ -132,7 +143,7 @@ import weakref _PANEL_KEYS = {"title", "metrics", "secondary", "xlabel", "ylabel", "ylabel2", "xlim", "ylim", "ylim2", "axhlines", "axhlines2", - "axvlines", "smooth", "yscale", "yscale2", "kind", "cmap", "legend"} + "axvlines", "smooth", "xscale", "yscale", "yscale2", "kind", "cmap", "legend", "styles", "kw"} _REF_LINE_STYLE = {"linestyle": "--", "linewidth": 1, "color": "0.45"} # defaults for axhline / axvline artists @@ -185,7 +196,7 @@ def _normalise_panel(spec, allow_empty: bool = False) -> dict: assert metrics or secondary or kind == "image" or allow_empty, f"a panel needs at least one metric: {spec!r}" for lim in ("xlim", "ylim", "ylim2"): if spec.get(lim) is not None: - assert len(spec[lim]) == 2, f"{lim} must be a (low, high) pair" + assert len(spec[lim]) == 2, f"{lim} must be a (low, high) pair (either may be None)" return { "title": spec.get("title") or " / ".join(metrics + secondary), "metrics": metrics, @@ -200,11 +211,15 @@ def _normalise_panel(spec, allow_empty: bool = False) -> dict: "axhlines2": _normalise_axhlines(spec.get("axhlines2")), # ... and on the right axis "axvlines": [dict(kw) for kw in spec.get("axvlines") or []], # vertical lines on this panel only (axvline kwargs) "smooth": _check_smooth(spec.get("smooth")), # TWEMA weight in [0, 1); None = plot-wide default; 0 = off - "yscale": spec.get("yscale", "linear"), # "linear" or "log", left axis + "xscale": spec.get("xscale", "linear"), # any matplotlib scale name: "linear", "log", "symlog", ... + "yscale": spec.get("yscale", "linear"), # ... left axis "yscale2": spec.get("yscale2", "linear"), # ... right axis "kind": kind, # "curve" (metric lines) or "image" (one picture, overwritten in place) "cmap": spec.get("cmap", "gray"), # image panels only; matplotlib ignores it for RGB data "legend": dict(spec.get("legend") or {}), # Axes.legend kwargs (loc, ncols, bbox_to_anchor, ...) + "styles": {k: dict(v) for k, v in (spec.get("styles") or {}).items()}, # metric -> Line2D kwargs, from ax.plot + "kw": {k: dict(v) for k, v in (spec.get("kw") or {}).items()}, # extra matplotlib kwargs for the setters: + # "title", "xlabel", "ylabel", "ylabel2" (Text kwargs), "xscale", "yscale", "yscale2", "imshow" } @@ -266,19 +281,22 @@ class _FigureRenderer: """ def __init__(self, panels, xlim, layout, hist=None, images=None): - self.xlim, self.layout = xlim, layout # xlim = default x range or None; layout = (max_cols, rows, cols, cell_size, dpi, xlabel) + # xlim = default x range or None; layout = (max_cols, rows, cols, cell_size, dpi, xlabel[, figure_kw]) + self.xlim, self.layout = xlim, layout self.hist = hist if hist is not None else {} self.images = dict(images) if images else {} # panel index -> (the uint8 array it is showing, its x) - self.axvlines: list[dict] = [] # vertical reference lines (matplotlib axvline kwargs), drawn on every panel self.set_layout(panels) def set_layout(self, panels): import matplotlib from matplotlib.backends.backend_agg import FigureCanvasAgg + from matplotlib.cbook import normalize_kwargs from matplotlib.figure import Figure + from matplotlib.lines import Line2D max_cols, rows_opt, cols_opt, cell_size, dpi, xlabel, *rest = self.layout - gridspec_kw = rest[0] if rest else {} # width_ratios / height_ratios, from subplots() + figure_kw = rest[0] if rest else {} # subplots()' grid options, and suptitle / supxlabel / supylabel + gridspec_kw = dict(figure_kw.get("gridspec_kw") or {}) n = len(panels) rows, cols = _grid_shape(n, max_cols, rows_opt, cols_opt) self.panels = panels @@ -293,76 +311,100 @@ def set_layout(self, panels): self.fig = Figure(figsize=(fig_width, cell_size[1] * rows)) FigureCanvasAgg(self.fig) self.dpi = dpi - axes = self.fig.subplots(rows, cols, squeeze=False, gridspec_kw=gridspec_kw).flatten() + axes = self.fig.subplots(rows, cols, squeeze=False, gridspec_kw=gridspec_kw, + sharex=figure_kw.get("sharex", False), sharey=figure_kw.get("sharey", False)).flatten() + for method in ("suptitle", "supxlabel", "supylabel"): + if figure_kw.get(method): + text, kw = figure_kw[method] + getattr(self.fig, method)(text, **kw) palette = matplotlib.rcParams["axes.prop_cycle"].by_key()["color"] self.lines = {} self.raw_lines = {} # metric -> faded line of the unsmoothed values, on smoothed panels self.smooth = {} # metric -> TWEMA weight in (0, 1) or None - self.fixed = {} # axes -> (x fixed?, y fixed?): fixed axes are never autoscaled - self.panel_cmaps = {i: p["cmap"] for i, p in enumerate(panels)} - self.panel_titles = {i: p["title"] for i, p in enumerate(panels)} + self.limits = {} # axes -> (xlim, ylim) as the user set them: None, (lo, hi), or one-sided with a None + self.panel_titles = {i: (p["title"], p["kw"].get("title", {})) for i, p in enumerate(panels)} + self.panel_imshow = {i: {"cmap": p["cmap"], "interpolation": "nearest", **p["kw"].get("imshow", {})} + for i, p in enumerate(panels)} self.image_axes = {} # panel index -> its Axes, for the image panels self.image_artists = {} # ... and the AxesImage drawn on it, so a redraw can set_data in place for i, (ax, panel) in enumerate(zip(axes, panels)): + kw = panel["kw"] if panel["kind"] == "image": - ax.set_title(panel["title"]) + ax.set_title(panel["title"], **kw.get("title", {})) ax.set_axis_off() # pixel indices along the edge of a tiled grid are just noise self.image_axes[i] = ax continue names = panel["metrics"] + panel["secondary"] ax2 = ax.twinx() if panel["secondary"] else None - ax.set_yscale(panel["yscale"]) + ax.set_xscale(panel["xscale"], **kw.get("xscale", {})) + ax.set_yscale(panel["yscale"], **kw.get("yscale", {})) if ax2 is not None: - ax2.set_yscale(panel["yscale2"]) + ax2.set_yscale(panel["yscale2"], **kw.get("yscale2", {})) window = panel["smooth"] if panel["smooth"] else None # TWEMA weight in (0, 1) assert window is None or 0 < window < 1, f"smooth must be a weight in (0, 1), got {window!r}" - handles = [] for j, name in enumerate(names): target = ax2 if name in panel["secondary"] else ax - color = palette[j % len(palette)] + style = {"color": palette[j % len(palette)], "linewidth": 1.2, "label": name, + **normalize_kwargs(panel["styles"].get(name, {}), Line2D)} if window: - (self.raw_lines[name],) = target.plot([], [], lw=0.8, color=color, alpha=0.25) - (line,) = target.plot([], [], lw=1.2, color=color, label=name) - self.lines[name] = line + (self.raw_lines[name],) = target.plot([], [], color=style["color"], linewidth=0.8, alpha=0.25, + linestyle=style.get("linestyle", "-"), label="_raw") + (self.lines[name],) = target.plot([], [], **style) self.smooth[name] = window self.hist.setdefault(name, ([], [])) - handles.append(line) for target, key in ((ax, "axhlines"), (ax2, "axhlines2")): - for kw in panel[key] if target is not None else []: - handles.append(target.axhline(**{**_REF_LINE_STYLE, **kw})) - names = names + [kw["label"]] - ax.set_title(panel["title"]) - ax.set_xlabel(panel["xlabel"] or xlabel) + for line_kw in panel[key] if target is not None else []: + target.axhline(**{**_REF_LINE_STYLE, **line_kw}) + for line_kw in panel["axvlines"]: + ax.axvline(**{**_REF_LINE_STYLE, "linestyle": ":", **line_kw}) + ax.set_title(panel["title"], **kw.get("title", {})) + ax.set_xlabel(panel["xlabel"] or xlabel, **kw.get("xlabel", {})) xlim = panel["xlim"] or self.xlim - if xlim: - ax.set_xlim(*xlim) - if panel["ylim"]: - ax.set_ylim(*panel["ylim"]) - self.fixed[ax] = (xlim is not None, panel["ylim"] is not None) - if names and names != ["_"]: # (nothing logged yet, or the "waiting for data" placeholder) - # every panel gets a legend (reference lines included); Panel.legend(**kwargs) moves or styles it - ax.legend(handles, names, **{"loc": "best", "fontsize": 8, **panel["legend"]}) + self.limits[ax] = (xlim, panel["ylim"]) if ax2 is not None: - ax.set_ylabel(panel["ylabel"] or ", ".join(panel["metrics"])) - ax2.set_ylabel(panel["ylabel2"] or ", ".join(panel["secondary"])) - if panel["ylim2"]: - ax2.set_ylim(*panel["ylim2"]) - self.fixed[ax2] = (xlim is not None, panel["ylim2"] is not None) + ax.set_ylabel(panel["ylabel"] or ", ".join(panel["metrics"]), **kw.get("ylabel", {})) + ax2.set_ylabel(panel["ylabel2"] or ", ".join(panel["secondary"]), **kw.get("ylabel2", {})) + self.limits[ax2] = (xlim, panel["ylim2"]) elif panel["ylabel"]: - ax.set_ylabel(panel["ylabel"]) + ax.set_ylabel(panel["ylabel"], **kw.get("ylabel", {})) + for target in (ax, ax2): + if target is not None: + self._apply_limits(target) + # One legend per panel, for the curves on both y-axes and the labelled reference lines, as + # matplotlib collects it: an artist whose label starts with "_" is left out. The metrics come + # first, then the reference lines. + handles, labels = ax.get_legend_handles_labels() + if ax2 is not None: + handles2, labels2 = ax2.get_legend_handles_labels() + handles, labels = handles + handles2, labels + labels2 + curves = set(map(id, self.lines.values())) + order = sorted(range(len(handles)), key=lambda k: id(handles[k]) not in curves) + if handles: # (nothing logged yet, or the "waiting for data" placeholder) + ax.legend([handles[k] for k in order], [labels[k] for k in order], + **{"loc": "best", "fontsize": 8, **panel["legend"]}) for ax in axes[n:]: ax.set_visible(False) self.axes = list(axes[:n]) - for ax, panel in zip(self.axes, panels): - for kw in panel["axvlines"]: - self._draw_axvline(kw, [ax]) - for kw in self.axvlines: - self._draw_axvline(kw) for index in self.images: # a re-layout rebuilds the figure; put the pictures back if index in self.image_axes: self._draw_image(index) self.fig.tight_layout(pad=0.6) + def _apply_limits(self, ax, autoscale: bool = False): + """ + The user's limits on `ax`. Both ends given: fixed, and never autoscaled. One end given, as in + matplotlib's `set_ylim(bottom=0)`: the other end follows the data, so autoscale first and then + pin the given end. + """ + xlim, ylim = self.limits[ax] + whole = [lim is not None and None not in lim for lim in (xlim, ylim)] + if autoscale: + ax.relim() + ax.autoscale_view(scalex=not whole[0], scaley=not whole[1]) + for lim, full, setter in ((xlim, whole[0], ax.set_xlim), (ylim, whole[1], ax.set_ylim)): + if lim is not None: + setter(*lim) if full else setter(*lim, auto=None) # auto=None: keep autoscaling the free end + def _fitted_widths(self, panels, cell_size, fig_width): """ Panel widths in inches for a one-row figure: an image panel is as wide as its picture is at the @@ -384,18 +426,6 @@ def _fitted_widths(self, panels, cell_size, fig_width): for i in curves: widths[i] = max(spare, 0.6 * cell_w) return widths, aspects - def add_axvline(self, kw: dict): - self.axvlines.append(kw) - self._draw_axvline(kw) - - def _draw_axvline(self, kw, axes=None): - label = kw.get("label") - style = {**_REF_LINE_STYLE, "linestyle": ":", **{k: v for k, v in kw.items() if k != "label"}} - picture = set(self.image_axes.values()) # a vertical line across a picture means nothing - for ax in ([a for a in self.axes if a not in picture] if axes is None else axes): - ax.axvline(**style) - if label: - ax.text(kw["x"], 0.98, f" {label}", transform=ax.get_xaxis_transform(), va="top", ha="left", fontsize=7, color="0.3", rotation=90) def add_image(self, index: int, arr, x=None): self.images[index] = (arr, x) @@ -408,16 +438,15 @@ def _draw_image(self, index: int): return self.set_layout(self.panels) # a picture of a new shape: re-fit the panel widths (draws it too) ax = self.image_axes[index] if x is not None: # say how old the picture is: it is only redrawn when imshow is called - title, when = self.panel_titles[index], f"{self.layout[5]} {x:g}" - ax.set_title(f"{title} ({when})" if title else when) + (title, title_kw), when = self.panel_titles[index], f"{self.layout[5]} {x:g}" + ax.set_title(f"{title} ({when})" if title else when, **title_kw) artist = self.image_artists.get(index) if artist is not None and artist.get_array().shape == arr.shape: artist.set_data(arr) # same size as last time: no new artist, no rescale return if artist is not None: artist.remove() - cmap = self.panel_cmaps.get(index, "gray") - self.image_artists[index] = ax.imshow(arr, cmap=cmap, interpolation="nearest") + self.image_artists[index] = ax.imshow(arr, **self.panel_imshow[index]) def add(self, step, metrics: dict): for name, value in metrics.items(): @@ -434,10 +463,8 @@ def render(self) -> bytes: line.set_data(xs, _twema(xs, ys, self.smooth[name], x_range)) else: line.set_data(xs, ys) - for line in self.lines.values(): - fixed_x, fixed_y = self.fixed[line.axes] - line.axes.relim() - line.axes.autoscale_view(scalex=not fixed_x, scaley=not fixed_y) + for ax in self.limits: + self._apply_limits(ax, autoscale=True) buf = io.BytesIO() self.fig.savefig(buf, format="png", dpi=self.dpi) return buf.getvalue() @@ -485,9 +512,6 @@ def _render_worker(panels, inbox, outbox, refresh_seconds, layout, xlim, parent_ renderer.layout = item[2] # grid options can change after start (subplots' width_ratios) renderer.set_layout(item[1]) dirty = True - elif item[0] == "axvline": - renderer.add_axvline(item[1]) - dirty = True elif item[0] == "image": renderer.add_image(*item[1:]) dirty = True @@ -551,19 +575,138 @@ def _collect_loop(plot_ref, outbox, proc, done): # --------------------------------------------------------------------------- panels and axes, addressed like matplotlib -def _lim(a, b): - """Accept matplotlib's forms: set_xlim((lo, hi)), set_xlim(lo, hi), set_xlim(lo) / set_xlim(right=hi) not supported.""" - if b is None and isinstance(a, (tuple, list)): - a, b = a - assert a is not None and b is not None, "give both limits, e.g. set_ylim(0, 1)" - return (float(a), float(b)) +def _lim(lo, hi, lo_alias=None, hi_alias=None): + """ + matplotlib's limit arguments -> (lo, hi), either of which may be None to leave that end to the + data: set_ylim(0, 1), set_ylim((0, 1)), set_ylim(bottom=0), set_ylim(top=1), set_ylim(ymin=0). + Both None (a bare `set_ylim()`) -> None, i.e. autoscale again. + """ + if hi is None and isinstance(lo, (tuple, list)): + lo, hi = lo + for name, value, alias in (("lower", lo, lo_alias), ("upper", hi, hi_alias)): + assert value is None or alias is None, f"give the {name} limit once, not under two names" + lo = lo if lo is not None else lo_alias + hi = hi if hi is not None else hi_alias + if lo is None and hi is None: + return None + return (None if lo is None else float(lo), None if hi is None else float(hi)) + + +_FMT_LINESTYLES = ("--", "-.", "-", ":") +_FMT_MARKERS = set(".,ov^<>12348spP*hH+xXDd|_") +_FMT_COLORS = set("bgrcmykw") + + +def _parse_fmt(fmt: str) -> dict | None: + """ + matplotlib's plot format string, '[marker][line][color]' in any order ("r--", "o-", "C2:", "k"), + or a lone colour of any kind ("tab:orange", "#1f77b4") -> Line2D kwargs; None if `fmt` is none + of these (then it is taken as a metric name). A marker with no line style means markers only. + """ + style, i = {}, 0 + while i < len(fmt): + two = fmt[i:i + 2] + if two in ("--", "-.") and "linestyle" not in style: + style["linestyle"], i = two, i + 2 + continue + c = fmt[i] + if c in "-:" and "linestyle" not in style: + style["linestyle"] = c + elif c == "C" and fmt[i + 1:i + 2].isdigit() and "color" not in style: + j = i + 1 + while j < len(fmt) and fmt[j].isdigit(): + j += 1 + style["color"], i = fmt[i:j], j + continue + elif c in _FMT_COLORS and "color" not in style: + style["color"] = c + elif c in _FMT_MARKERS and "marker" not in style: + style["marker"] = c + else: + from matplotlib.colors import is_color_like + + return {"color": fmt} if is_color_like(fmt) else None + i += 1 + if "marker" in style and "linestyle" not in style: + style["linestyle"] = "None" + return style or None + + +_scratch_axes = None + + +def _try(method: str, *args, **kwargs): + """ + Call `Axes.(*args, **kwargs)` on a scratch matplotlib Axes and throw the result away. The + real drawing happens later, in the render process, where a misspelt keyword or an unknown scale + would only kill the renderer; trying it here first raises matplotlib's own error at the line that + made the mistake. + """ + global _scratch_axes + if _scratch_axes is None: + from matplotlib.figure import Figure + + _scratch_axes = Figure().add_subplot() + try: + getattr(_scratch_axes, method)(*args, **kwargs) + finally: + _scratch_axes.cla() + + +_DATA_KWARGS = {"data", "xdata", "ydata", "transform", "axes", "figure"} # liveplot's to set, not the caller's + + +class _Line: + """ + One metric's line, as `ax.plot` returns it (in a one-element list, like matplotlib's, so + `(line,) = ax.plot("loss")` works). It takes Line2D's setters for restyling later: + `line.set_color("red")`, `line.set_linestyle("--")`, `line.set(alpha=0.5, label="train")`. + """ + + def __init__(self, plot, index: int, name: str): + self._plot, self._index, self._name = plot, index, name + + @property + def _styles(self) -> dict: + return self._plot._specs[self._index]["styles"] + + def set(self, **kwargs): + """Like `Line2D.set`: any Line2D property, e.g. `line.set(color="k", linewidth=2)`.""" + style = {**self._styles.get(self._name, {}), **kwargs} + _check_line_style(style) + self._styles[self._name] = style + self._plot._send_layout() + + def get_label(self) -> str: + return self._styles.get(self._name, {}).get("label", self._name) + + def __getattr__(self, attr): + if attr.startswith("set_"): # set_color(c), set_linewidth(w), ...: Line2D's setters, via set() + return lambda value: self.set(**{attr[4:]: value}) + raise AttributeError(attr) + + def __repr__(self): + return f"" + + +def _check_line_style(style: dict): + """Line2D kwargs as `ax.plot` would take them, checked on a scratch Axes (see `_try`).""" + taken = sorted(_DATA_KWARGS & set(style)) + assert not taken, f"{taken}: liveplot sets these itself; a metric's data comes from log()" + _try("plot", [], [], **style) + + +def _text_kw(kwargs: dict, **named) -> dict: + """matplotlib's text setters' named arguments (fontdict, loc, pad, labelpad), where given, and the rest.""" + return {**{k: v for k, v in named.items() if v is not None}, **kwargs} class _Axis: """ One y-axis of a panel (left, or the right-hand `secondary` one), with matplotlib's `Axes` names: - set_ylabel / set_ylim / set_yscale / axhline / set(**kwargs). Panel-level setters (title, xlabel, - xlim, axvline, smooth) are available here too, as they are on a matplotlib Axes. + plot / set_ylabel / set_ylim / set_yscale / axhline / set(**kwargs). Panel-level setters (title, + xlabel, xlim, xscale, axvline, legend, smooth) are available here too, as they are on a matplotlib + Axes. """ def __init__(self, plot, index: int, right: bool): @@ -577,44 +720,90 @@ def panel(self): def metrics(self) -> list: return list(self._plot._specs[self._index]["secondary" if self._right else "metrics"]) - def _set(self, key, value): - self._plot._specs[self._index][key + ("2" if self._right else "")] = value + @property + def lines(self) -> list: + """Like `Axes.lines`: a handle on each metric's line on this axis, for restyling.""" + return [_Line(self._plot, self._index, name) for name in self.metrics] + + def _key(self, key): + return key + ("2" if self._right else "") + + def _set(self, key, value, kw=None): + spec = self._plot._specs[self._index] + spec[self._key(key)] = value + if kw is not None: + spec["kw"][self._key(key)] = kw self._plot._send_layout() - def plot(self, *metrics): + def plot(self, *args, **kwargs) -> list: """ - Put these metrics on this axis: `ax.plot("lossD", "lossG")`. matplotlib spells the same - thing `ax.plot("lossD", data=d)`, naming series in a data source; here the source is the - plot itself, filled in later by `log()`. Returns the axis, so calls can be chained. + Like `Axes.plot`, with a metric's name where matplotlib takes data: `ax.plot("loss")` draws the + metric `loss` against the step, as matplotlib's `ax.plot("loss", data=d)` draws d["loss"] + against its index; `log()` supplies the data later. A format string and Line2D kwargs style + it: `ax.plot("loss", "r--", label="train loss", alpha=0.8)`. Returns `[line]`, like matplotlib, + and the line takes Line2D's setters: `(line,) = ax.plot("loss"); line.set_color("k")`. + + One metric per call. matplotlib reads `ax.plot("a", "b", data=d)` as b against a, so two + names are an error here rather than two curves. """ - assert metrics, "plot() needs at least one metric name" - assert all(isinstance(m, str) for m in metrics), \ - f"plot() takes metric names, not data: {[m for m in metrics if not isinstance(m, str)]!r}" - panel = self._plot._specs[self._index] - assert panel["kind"] == "curve", "this panel is showing an image; use a different panel for curves" + assert args, 'plot() needs a metric name: ax.plot("loss")' + name, *rest = args + assert isinstance(name, str), ( + f"plot() takes a metric's name, not data (got {type(name).__name__}): the data comes from " + f'log(). Name the metric here, e.g. ax.plot("loss"), then plot.log(loss=...) in the loop' + ) + style = _parse_fmt(rest[0]) if len(rest) == 1 and isinstance(rest[0], str) else None + if rest and style is None: + names = [a for a in args if isinstance(a, str)] + raise TypeError( + f"ax.plot({', '.join(map(repr, args))}): one metric per call. matplotlib reads two names as x " + f"and y ({args[1]!r} against {args[0]!r}); liveplot plots every metric against the step, so " + f"call plot once for each: " + "; ".join(f"ax.plot({n!r})" for n in names) + ) + style = {**(style or {}), **kwargs} + _check_line_style(style) + spec = self._plot._specs[self._index] + assert spec["kind"] == "curve", "this panel is showing an image; use a different panel for curves" + where = self._plot._axis_of(name) + if where is not None and where != (self._index, self._right): + raise ValueError( + f"{name!r} is already drawn on {self._plot[name]!r}; a metric lives on one axis. If log() " + f"placed it before this call, call ax.plot({name!r}) before the first log()" + ) key = "secondary" if self._right else "metrics" - auto = " / ".join(panel["metrics"] + panel["secondary"]) - was_auto = panel["title"] in (auto, "") # unnamed, or named after the metrics it had - panel[key].extend(m for m in metrics if m not in panel[key]) - if was_auto: # a title set with set_title() survives a later plot() call - panel["title"] = " / ".join(panel["metrics"] + panel["secondary"]) - self._plot._place(metrics) - return self - - def set_ylabel(self, ylabel): - self._set("ylabel", str(ylabel)) - - def set_ylim(self, bottom=None, top=None): - self._set("ylim", _lim(bottom, top)) - - def set_yscale(self, value): - assert value in ("linear", "log"), f"yscale must be 'linear' or 'log', got {value!r}" - self._set("yscale", value) - - def axhline(self, y, label=None, **kwargs): - """Like `Axes.axhline`: a horizontal reference line on this axis, with `label` in the legend.""" - line = {**kwargs, "y": float(y), "label": str(label) if label is not None else f"{float(y):g}"} - self._plot._specs[self._index]["axhlines2" if self._right else "axhlines"].append(line) + if name not in spec[key]: + auto = " / ".join(spec["metrics"] + spec["secondary"]) + was_auto = spec["title"] in (auto, "") # unnamed, or named after the metrics it had + spec[key].append(name) + if was_auto: # a title set with set_title() survives a later plot() call + spec["title"] = " / ".join(spec["metrics"] + spec["secondary"]) + if style: + spec["styles"][name] = {**spec["styles"].get(name, {}), **style} + self._plot._place([name]) + return [_Line(self._plot, self._index, name)] + + def set_ylabel(self, ylabel, fontdict=None, labelpad=None, *, loc=None, **kwargs): + kw = _text_kw(kwargs, fontdict=fontdict, labelpad=labelpad, loc=loc) + _try("set_ylabel", str(ylabel), **kw) + self._set("ylabel", str(ylabel), kw) + + def set_ylim(self, bottom=None, top=None, *, ymin=None, ymax=None): + """Like `Axes.set_ylim`: `set_ylim(0, 1)`, `set_ylim((0, 1))`, or one end, `set_ylim(bottom=0)`.""" + self._set("ylim", _lim(bottom, top, ymin, ymax)) + + def set_yscale(self, value, **kwargs): + """Like `Axes.set_yscale`: "linear", "log", "symlog", "logit", ..., with the scale's kwargs.""" + _try("set_yscale", value, **kwargs) + self._set("yscale", value, kwargs) + + def axhline(self, y=0, xmin=0, xmax=1, **kwargs): + """ + Like `Axes.axhline`: a horizontal line at `y`, dashed grey unless kwargs say otherwise. Give it + a `label` to put it in the legend, e.g. `ax.axhline(math.log(10), label="chance", color="r")`. + """ + line = {**kwargs, "y": float(y), **({"xmin": xmin, "xmax": xmax} if (xmin, xmax) != (0, 1) else {})} + _try("axhline", **line) + self._plot._specs[self._index][self._key("axhlines")].append(line) self._plot._send_layout() def set(self, **kwargs): @@ -623,29 +812,40 @@ def set(self, **kwargs): getattr(self, f"set_{key}")(value) # panel-level setters, so plot["acc"].set_title(...) works as it would on a matplotlib Axes - def set_title(self, label): - self.panel.set_title(label) + def set_title(self, label, fontdict=None, loc=None, pad=None, **kwargs): + self.panel.set_title(label, fontdict, loc, pad, **kwargs) def legend(self, **kwargs): self.panel.legend(**kwargs) - def set_xlabel(self, xlabel): - self.panel.set_xlabel(xlabel) + def set_xlabel(self, xlabel, fontdict=None, labelpad=None, *, loc=None, **kwargs): + self.panel.set_xlabel(xlabel, fontdict, labelpad, loc=loc, **kwargs) + + def set_xlim(self, left=None, right=None, *, xmin=None, xmax=None): + self.panel.set_xlim(left, right, xmin=xmin, xmax=xmax) - def set_xlim(self, left=None, right=None): - self.panel.set_xlim(left, right) + def set_xscale(self, value, **kwargs): + self.panel.set_xscale(value, **kwargs) - def axvline(self, x=None, label=None, **kwargs): - self.panel.axvline(x, label, **kwargs) + def axvline(self, x=0, ymin=0, ymax=1, **kwargs): + self.panel.axvline(x, ymin, ymax, **kwargs) def set_smooth(self, weight): self.panel.set_smooth(weight) + def twinx(self): + return self.panel.twinx() + + def __repr__(self): + side = "right" if self._right else "left" + return f"" + class Panel: """ - One panel of the grid: `plot.panels[i]`, or `plot[metric].panel`. `.left` / `.right` are its y-axes - (`.right` exists only if the panel has secondary metrics). y-setters here act on the left axis. + One panel of the grid, the Axes of a matplotlib figure: `plot.axes[i]`, what `LivePlot.subplots` + hands out, or `plot[metric].panel`. `.left` / `.right` are its y-axes (`.right` exists only if the + panel has secondary metrics; `twinx()` makes it). y-setters here act on the left axis. """ def __init__(self, plot, index: int): @@ -659,25 +859,30 @@ def spec(self) -> dict: def metrics(self) -> list: return list(self.spec["metrics"]) + list(self.spec["secondary"]) + @property + def lines(self) -> list: + """Like `Axes.lines`: a handle on each metric's line on this panel (both y-axes), for restyling.""" + return [_Line(self._plot, self._index, name) for name in self.metrics] + @property def left(self) -> _Axis: return _Axis(self._plot, self._index, right=False) @property def right(self) -> _Axis: - assert self.spec["secondary"], "this panel has no right-hand axis (no metrics after '|')" + assert self.spec["secondary"], "this panel has no right-hand axis (no metrics after '|'; twinx() makes one)" return _Axis(self._plot, self._index, right=True) def twinx(self) -> _Axis: """Like `Axes.twinx`: this panel's right-hand y-axis, whether or not it holds anything yet.""" return _Axis(self._plot, self._index, right=True) - def plot(self, *metrics) -> _Axis: - """Like `ax.plot("name", data=...)`: put these metrics on the left axis. See `_Axis.plot`.""" - return self.left.plot(*metrics) + def plot(self, *args, **kwargs) -> list: + """Like `Axes.plot`, on the left axis: `ax.plot("loss", "r--", label="train")`. See `_Axis.plot`.""" + return self.left.plot(*args, **kwargs) def imshow(self, x, *, rows=None, cols=None, griddim=None, vmin=None, vmax=None, scale_each=False, - channels=None, cmap="gray", padding=0, pad_value=0, max_images=64, max_cols=8): + channels=None, cmap="gray", padding=0, pad_value=0, max_images=64, max_cols=8, **kwargs): """ Like `Axes.imshow`, but for a whole batch and repeatable: show `x` on this panel, replacing whatever was there. A batch is tiled into a grid -- `rows` or `cols` alone infers the other, @@ -685,7 +890,8 @@ def imshow(self, x, *, rows=None, cols=None, griddim=None, vmin=None, vmax=None, the grid is near-square (3 -> 2x2, 5 -> 3x2, 8 -> 3x3, 64 -> 8x8), at most `max_cols` wide. Only the first `max_images` are shown, with a warning (`max_images=None` for all of them). Images sit edge to edge; `padding` puts that many pixels of `pad_value` between them. The - title shows the step the image was drawn at. + title shows the step the image was drawn at. Other kwargs (`interpolation`, `aspect`, ...) go + to matplotlib's `imshow`; the default interpolation is "nearest", so pixels stay crisp. Accepts (H, W), (C, H, W), (H, W, C), (B, H, W), (B, C, H, W) and (B, H, W, C); (3, H, W) and (4, H, W) are ambiguous and raise, telling you which `channels=` to pass. @@ -704,106 +910,94 @@ def imshow(self, x, *, rows=None, cols=None, griddim=None, vmin=None, vmax=None, arr = to_grid(x, rows=rows, cols=cols, griddim=griddim, vmin=vmin, vmax=vmax, scale_each=scale_each, channels=channels, padding=padding, pad_value=pad_value, max_images=max_images, max_cols=max_cols) - if spec["kind"] != "image" or spec["cmap"] != cmap: - spec["kind"], spec["cmap"] = "image", cmap + if kwargs != spec["kw"].get("imshow", {}): + taken = sorted({"X", "vmin", "vmax", "norm"} & set(kwargs)) + assert not taken, f"{taken}: liveplot scales the pixels itself (see vmin / vmax above)" + _try("imshow", [[0, 1], [1, 0]], **kwargs) + if spec["kind"] != "image" or spec["cmap"] != cmap or spec["kw"].get("imshow", {}) != kwargs: + spec["kind"], spec["cmap"], spec["kw"]["imshow"] = "image", cmap, kwargs self._plot._send_layout() self._plot._send_image(self._index, arr) return self - def _set(self, key, value): + def _set(self, key, value, kw=None): self.spec[key] = value + if kw is not None: + self.spec["kw"][key] = kw self._plot._send_layout() - def set_title(self, label): - self._set("title", str(label)) + def set_title(self, label, fontdict=None, loc=None, pad=None, **kwargs): + """Like `Axes.set_title`, text kwargs included: `set_title("training", fontsize=10, loc="left")`.""" + kw = _text_kw(kwargs, fontdict=fontdict, loc=loc, pad=pad) + _try("set_title", str(label), **kw) + self._set("title", str(label), kw) - def legend(self, **kwargs): + def legend(self, *args, **kwargs): """ - Like `Axes.legend`, for the panel's one legend (both y-axes' curves and its reference lines): - kwargs go to matplotlib, e.g. `legend(loc="upper left", ncols=3)`, or + Like `Axes.legend`, for the panel's one legend (both y-axes' curves and its labelled reference + lines): kwargs go to matplotlib, e.g. `legend(loc="upper left", ncols=3)`, or `legend(loc="upper center", bbox_to_anchor=(0.5, -0.15), ncols=3)` to put it under the panel. + Every panel has a legend already; this only moves or styles it. What it lists comes from the + labels: `ax.plot("loss", label="train")`, `axhline(..., label=...)`, and a label starting with + "_" leaves an artist out, as in matplotlib. """ + assert not args, ( + "legend() takes keyword arguments only: the entries come from the labels given to plot(), " + "axhline() and axvline() (a label starting with '_' leaves one out)" + ) + _try("legend", [], [], **kwargs) self._set("legend", kwargs) - def set_xlabel(self, xlabel): - self._set("xlabel", str(xlabel)) + def set_xlabel(self, xlabel, fontdict=None, labelpad=None, *, loc=None, **kwargs): + kw = _text_kw(kwargs, fontdict=fontdict, labelpad=labelpad, loc=loc) + _try("set_xlabel", str(xlabel), **kw) + self._set("xlabel", str(xlabel), kw) + + def set_xlim(self, left=None, right=None, *, xmin=None, xmax=None): + """Like `Axes.set_xlim`: `set_xlim(0, 1000)`, `set_xlim((0, 1000))`, or one end, `set_xlim(left=100)`.""" + self._set("xlim", _lim(left, right, xmin, xmax)) - def set_xlim(self, left=None, right=None): - self._set("xlim", _lim(left, right)) + def set_xscale(self, value, **kwargs): + """Like `Axes.set_xscale`: "linear", "log", "symlog", ..., with the scale's kwargs.""" + _try("set_xscale", value, **kwargs) + self._set("xscale", value, kwargs) def set_smooth(self, weight): """wandb-style smoothing weight in [0, 1) for every curve on this panel; 0 turns it off.""" self._set("smooth", _check_smooth(weight)) - def axvline(self, x=None, label=None, **kwargs): - """Like `Axes.axvline`, on this panel only: a vertical line at `x` (default: the current step).""" - line = {**kwargs, "x": float(self._plot.step if x is None else x), "label": label} + def axvline(self, x=0, ymin=0, ymax=1, **kwargs): + """ + Like `Axes.axvline`: a vertical line at `x`, dotted grey unless kwargs say otherwise, with a + `label` for the legend. To mark the current point of a run: `ax.axvline(plot.step, label="lr drop")`. + """ + line = {**kwargs, "x": float(x), **({"ymin": ymin, "ymax": ymax} if (ymin, ymax) != (0, 1) else {})} + _try("axvline", **line) self.spec["axvlines"].append(line) self._plot._send_layout() def set(self, **kwargs): - """Like `Axes.set`: `panel.set(title="training", xlabel="examples", ylim=(0, 1))`.""" + """Like `Axes.set`: `ax.set(title="training", xlabel="examples", ylim=(0, 1), yscale="log")`.""" for key, value in kwargs.items(): getattr(self, f"set_{key}")(value) # y-setters act on the left axis, as on a matplotlib Axes - def set_ylabel(self, ylabel): - self.left.set_ylabel(ylabel) + def set_ylabel(self, ylabel, fontdict=None, labelpad=None, *, loc=None, **kwargs): + self.left.set_ylabel(ylabel, fontdict, labelpad, loc=loc, **kwargs) - def set_ylim(self, bottom=None, top=None): - self.left.set_ylim(bottom, top) + def set_ylim(self, bottom=None, top=None, *, ymin=None, ymax=None): + self.left.set_ylim(bottom, top, ymin=ymin, ymax=ymax) - def set_yscale(self, value): - self.left.set_yscale(value) + def set_yscale(self, value, **kwargs): + self.left.set_yscale(value, **kwargs) - def axhline(self, y, label=None, **kwargs): - self.left.axhline(y, label, **kwargs) + def axhline(self, y=0, xmin=0, xmax=1, **kwargs): + self.left.axhline(y, xmin, xmax, **kwargs) def __repr__(self): return f"Panel({self._index}: {' '.join(self.spec['metrics'])}{' | ' + ' '.join(self.spec['secondary']) if self.spec['secondary'] else ''})" -class _PanelGrid: - """ - The panel array `LivePlot.subplots` returns, indexable the way matplotlib's is: `axes[0][1]` - and `axes[0, 1]` both work, `axes.flat` walks it in row-major order, and it unpacks. - """ - - def __init__(self, rows: list): - self._rows = rows - - def __getitem__(self, key): - if isinstance(key, tuple): - row, col = key - return self._rows[row][col] - return self._rows[key] - - def __len__(self): - return len(self._rows) - - def __iter__(self): - return iter(self._rows) - - @property - def flat(self) -> list: - return [panel for row in self._rows for panel in row] - - @property - def shape(self) -> tuple: - return (len(self._rows), len(self._rows[0])) - - def _squeezed(self): - """matplotlib's squeeze: 1x1 -> the panel, 1xN or Nx1 -> a flat list, else the grid.""" - flat = self.flat - if len(flat) == 1: - return flat[0] - rows, cols = self.shape - return flat if rows == 1 or cols == 1 else self - - def __repr__(self): - return f"_PanelGrid({self.shape[0]}x{self.shape[1]})" - - # --------------------------------------------------------------------------- the handle @@ -840,11 +1034,13 @@ def __init__( """ iterable, specs = (args[0], args[1:]) if args and not isinstance(args[0], (str, dict)) else (None, args) self.smooth = _check_smooth(smooth) # default TWEMA weight for panels that don't set their own (wandb's smoothing slider) - self._panel_defaults: dict = {} # plot-level set_*() values, applied to panels created later + self._panel_defaults: dict = {} # set_all() kwargs, applied to panels created later self._specs = [self._with_defaults(_normalise_panel(p)) for p in specs] self._explicit_layout = bool(self._specs) self._placed = {n for p in self._specs for n in p["metrics"] + p["secondary"]} - self._layout = (max_cols, rows, cols, cell_size, dpi, unit) + # the grid and figure options the renderer needs; the dict holds subplots()' grid options and + # the figure-level text (suptitle / supxlabel / supylabel) + self._layout = (max_cols, rows, cols, cell_size, dpi, unit, {}) self._iterable, self._bar, self._desc, self._progress = iterable, None, desc, progress self._own_bar = None # the bar update() opened, which finish() closes if total is None and iterable is not None: @@ -863,7 +1059,6 @@ def __init__( self._record = bool(record) self._record_path = record if isinstance(record, str) else None self.frames: list[tuple[float, bytes]] = [] # (time, png) of every frame shown, if record=True - self.axvlines: list[dict] = [] # vertical reference lines added with axvline() self._images: dict[int, object] = {} # panel index -> the uint8 array it is showing self._image_steps: dict[int, object] = {} # panel index -> the x it was shown at self._fixed_grid = False # set by subplots(): grow a panel's metrics, never the grid @@ -904,105 +1099,133 @@ def _make_display_handle(): def _with_defaults(self, panel): if panel["smooth"] is None: panel["smooth"] = self.smooth - for key, value in self._panel_defaults.items(): # plot-level set_xlabel(...) etc. made before the panel existed - panel[key] = value return panel + def _add_panel(self, spec) -> int: + """Append a panel, with the plot's smoothing default and whatever `set_all` has asked of every panel.""" + self._specs.append(self._with_defaults(spec)) + index = len(self._specs) - 1 + if self._panel_defaults: + Panel(self, index).set(**self._panel_defaults) + return index + @classmethod - def subplots(cls, nrows: int = 1, ncols: int = 1, *, figsize=None, squeeze: bool = True, - width_ratios=None, height_ratios=None, iterable=None, **kwargs): + def subplots(cls, nrows: int = 1, ncols: int = 1, *, sharex=False, sharey=False, squeeze: bool = True, + width_ratios=None, height_ratios=None, subplot_kw=None, gridspec_kw=None, figsize=None, + iterable=None, **kwargs): """ Like `plt.subplots`, for a live plot: returns `(plot, axes)` with a fixed nrows x ncols grid - of empty panels, which you then fill with `ax.plot("loss", ...)` or `ax.imshow(tensor)`. + of empty panels, which you then fill with `ax.plot("loss")` or `ax.imshow(tensor)`. plot, (ax_loss, ax_samples) = LivePlot.subplots(1, 2, total=n_steps, figsize=(11, 4)) - ax_loss.plot("lossD", "lossG") + ax_loss.plot("lossD") + ax_loss.plot("lossG") ax_samples.imshow(netG(fixed_noise), rows=2, vmin=-1, vmax=1) - `axes` follows matplotlib's squeeze rules: one panel for 1x1, a flat list for a single row - or column, a 2-d grid otherwise. `figsize` is the whole figure in inches, as matplotlib - means it (liveplot's own `cell_size` is per panel). `width_ratios` / `height_ratios` are - matplotlib's too, e.g. `width_ratios=(1, 1.3)` to give an image panel more room. Every other - keyword goes to `LivePlot`. + The arguments are matplotlib's. `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; `squeeze=False` always + 2-d), so `axes[0, 1]`, `axes.flat` and `axes.flatten()` all work. `figsize` is the whole + figure in inches (liveplot's own `cell_size` is per panel); `sharex` / `sharey`, + `width_ratios` / `height_ratios` and `gridspec_kw` go to matplotlib's grid; `subplot_kw` is + applied to every panel with `Panel.set`, e.g. `subplot_kw=dict(xlabel="examples")`. Every + other keyword goes to `LivePlot` (`total=`, `unit=`, ...). """ + import numpy as np + assert nrows >= 1 and ncols >= 1, f"need at least a 1x1 grid, got {nrows}x{ncols}" + for name, share in (("sharex", sharex), ("sharey", sharey)): + assert share in (True, False, "all", "row", "col", "none"), \ + f"{name} must be a bool or one of 'all', 'row', 'col', 'none', got {share!r}" kwargs.setdefault("rows", nrows) kwargs.setdefault("cols", ncols) if figsize is not None: kwargs["cell_size"] = (figsize[0] / ncols, figsize[1] / nrows) + gridspec_kw = dict(gridspec_kw or {}) + for key, value, count in (("width_ratios", width_ratios, ncols), ("height_ratios", height_ratios, nrows)): + if value is not None: + assert key not in gridspec_kw, f"'{key}' must not be defined both as parameter and as key in 'gridspec_kw'" + gridspec_kw[key] = list(value) + if key in gridspec_kw: + assert len(gridspec_kw[key]) == count, f"{key} needs one entry per {'column' if count == ncols else 'row'}" plot = cls(*([] if iterable is None else [iterable]), **kwargs) - gridspec_kw = {k: list(v) for k, v in (("width_ratios", width_ratios), ("height_ratios", height_ratios)) if v is not None} - if gridspec_kw: - assert len(gridspec_kw.get("width_ratios", [0] * ncols)) == ncols, "width_ratios needs one entry per column" - assert len(gridspec_kw.get("height_ratios", [0] * nrows)) == nrows, "height_ratios needs one entry per row" - plot._layout = (*plot._layout[:6], gridspec_kw) - plot._specs.extend( - plot._with_defaults(_normalise_panel({"metrics": []}, allow_empty=True)) for _ in range(nrows * ncols) - ) + plot._layout[6].update(gridspec_kw=gridspec_kw, sharex=sharex, sharey=sharey) + for _ in range(nrows * ncols): + plot._add_panel(_normalise_panel({"metrics": []}, allow_empty=True)) plot._explicit_layout = True plot._fixed_grid = True + axes = np.empty((nrows, ncols), dtype=object) + for r in range(nrows): + for c in range(ncols): + axes[r, c] = Panel(plot, r * ncols + c) + if subplot_kw: + axes[r, c].set(**subplot_kw) plot._send_layout() - grid = _PanelGrid([[Panel(plot, r * ncols + c) for c in range(ncols)] for r in range(nrows)]) - return plot, (grid._squeezed() if squeeze else grid) + if squeeze: + axes = axes.item() if axes.size == 1 else axes.squeeze() + return plot, axes - # -- panels and axes, addressed like matplotlib ----------------------------------- + # -- the figure: its panels, and figure-level text --------------------------------- @property - def panels(self) -> list: - """The panels of the grid, in order: `plot.panels[0].set_title("training")`.""" + def axes(self) -> list: + """Like `Figure.axes`: the panels, in order. `plot.axes[0].set_title("training")`.""" return [Panel(self, i) for i in range(len(self._specs))] + def _axis_of(self, metric: str): + """(panel index, right-hand axis?) of the axis `metric` is drawn on, or None if it isn't placed yet.""" + for i, spec in enumerate(self._specs): + if metric in spec["metrics"]: + return i, False + if metric in spec["secondary"]: + return i, True + return None + def __getitem__(self, metric: str) -> _Axis: """ The y-axis that holds `metric`: `plot["acc"].set_ylim(0, 1)`. Right-hand axes are found too, which is what makes the left/right distinction disappear from the API. A metric that hasn't - been logged yet gets its panel created now. + been logged yet gets its panel created now. (matplotlib has no lookup by series name; this is + liveplot's, for a plot whose panels came from logging rather than from `subplots`.) """ if metric not in self._placed: self.data.setdefault(metric, ([], [])) self._extend_layout([metric]) - for i, spec in enumerate(self._specs): - if metric in spec["metrics"]: - return _Axis(self, i, right=False) - if metric in spec["secondary"]: - return _Axis(self, i, right=True) - raise KeyError(metric) - - def _set_all(self, key, value): - """A plot-level setter: every existing panel, and every panel created later.""" - self._panel_defaults[key] = value - for spec in self._specs: - spec[key] = value - self._send_layout() - - def set_title(self, label): - self._set_all("title", str(label)) - - def set_xlabel(self, xlabel): - self._set_all("xlabel", str(xlabel)) + where = self._axis_of(metric) + if where is None: + raise KeyError(metric) + return _Axis(self, where[0], right=where[1]) - def set_xlim(self, left=None, right=None): - self._set_all("xlim", _lim(left, right)) + def _figure_text(self, method: str, text, kwargs): + from matplotlib.figure import Figure - def set_ylabel(self, ylabel): - self._set_all("ylabel", str(ylabel)) + getattr(Figure(), method)(str(text), **kwargs) # matplotlib's own check of the kwargs (see `_try`) + self._layout[6][method] = (str(text), kwargs) + self._send_layout() - def set_ylim(self, bottom=None, top=None): - self._set_all("ylim", _lim(bottom, top)) + def suptitle(self, t, **kwargs): + """Like `Figure.suptitle`: a title over the whole figure, e.g. `plot.suptitle("run 3", fontsize=12)`.""" + self._figure_text("suptitle", t, kwargs) - def set_yscale(self, value): - assert value in ("linear", "log"), f"yscale must be 'linear' or 'log', got {value!r}" - self._set_all("yscale", value) + def supxlabel(self, t, **kwargs): + """Like `Figure.supxlabel`: one x-label under the whole grid.""" + self._figure_text("supxlabel", t, kwargs) - def set_smooth(self, weight): - """wandb-style smoothing weight in [0, 1) for every panel (and the default for later ones).""" - self.smooth = _check_smooth(weight) - self._set_all("smooth", weight) + def supylabel(self, t, **kwargs): + """Like `Figure.supylabel`: one y-label beside the whole grid.""" + self._figure_text("supylabel", t, kwargs) - def set(self, **kwargs): - """Like `Axes.set`, on every panel: `plot.set(xlabel="examples", yscale="log")`.""" - for key, value in kwargs.items(): - getattr(self, f"set_{key}")(value) + def set_all(self, **kwargs): + """ + `Panel.set(**kwargs)` on every panel, now and for any panel created later (a metric logged for + the first time gets its panel with these settings too): `plot.set_all(xlabel="examples", + smooth=0.9, yscale="log")`. matplotlib's way is a loop over `fig.axes`, which also works here, + `for ax in plot.axes: ax.set(...)`, but a loop cannot reach panels that don't exist yet. + """ + for key in kwargs: + assert hasattr(Panel, f"set_{key}"), f"set_all({key}=...): a panel has no set_{key}()" + self._panel_defaults.update(kwargs) + for panel in self.axes: + panel.set(**kwargs) def _panels_or_placeholder(self): return self._specs or [_normalise_panel({"title": "waiting for data…", "metrics": ["_"]})] @@ -1198,9 +1421,9 @@ def _extend_layout(self, new_metrics): self._join_panel(empty[0] if empty else curves[0], unplaced) elif self._explicit_layout: for m in unplaced: - self._specs.append(self._with_defaults(_normalise_panel({"metrics": [m]}))) + self._add_panel(_normalise_panel({"metrics": [m]})) elif not curves: # nothing but image panels so far (plot.imshow() before the first log) - self._specs.append(self._with_defaults(_normalise_panel({"metrics": unplaced}))) + self._add_panel(_normalise_panel({"metrics": unplaced})) else: self._join_panel(curves[0], unplaced) self._placed.update(unplaced) @@ -1215,46 +1438,19 @@ def _join_panel(self, index: int, unplaced: list): if was_auto: # a title set with set_title() survives a newly discovered metric spec["title"] = " / ".join(spec["metrics"] + spec["secondary"]) - def axhline(self, y, label=None, *, metric=None, **kwargs): - """ - Like matplotlib's `Axes.axhline`: a horizontal reference line at `y` with `label` in the legend, - dashed grey by default; any other kwargs (color, linestyle, linewidth, alpha, ...) go to the - artist. It goes on the axis of `metric`'s panel, or on the left axis of every panel if `metric` - is None. E.g. the loss a model must beat: `plot.axhline(math.log(d_vocab), "uniform", metric="loss")`. - """ - if metric is not None: - return self[metric].axhline(y, label, **kwargs) - line = {**kwargs, "y": float(y), "label": str(label) if label is not None else f"{float(y):g}"} - for spec in self._specs: - spec["axhlines"].append(line) - self._send_layout() - - def axvline(self, x=None, label=None, **kwargs): - """ - Like matplotlib's `Axes.axvline`, on every panel: a vertical line at `x` (default: the current - step), dotted grey by default, with `label` written along it; other kwargs go to the artist. - E.g. `plot.axvline(label="lr drop")` where the learning rate changes. - """ - line = {**kwargs, "x": float(self.step if x is None else x), "label": label} - self.axvlines.append(line) - if self.mode == "process": - self._inbox.put(("axvline", line)) - elif self.mode == "thread": - self._renderer.add_axvline(line) - def imshow(self, x, **kwargs): """ - Show an image on the plot, replacing whatever was there -- the whole of `LiveImage` in one - call. Uses the plot's image panel, creating it on first use. Keywords go to `Panel.imshow`. + Like pyplot's `plt.imshow`, which draws on the current Axes: show an image on the plot's image + panel, creating it on first use, replacing whatever was there. Keywords go to `Panel.imshow`. """ for i, spec in enumerate(self._specs): if spec["kind"] == "image": return Panel(self, i).imshow(x, **kwargs) assert not self._fixed_grid, \ "this plot's grid comes from subplots(); call imshow() on one of its panels instead" - self._specs.append(self._with_defaults(_normalise_panel({"kind": "image"}))) + index = self._add_panel(_normalise_panel({"kind": "image"})) self._send_layout() - return Panel(self, len(self._specs) - 1).imshow(x, **kwargs) + return Panel(self, index).imshow(x, **kwargs) def _place(self, metrics): """Record metrics as already belonging to a panel, and redraw.""" @@ -1291,8 +1487,6 @@ def figure(self): renderer = _FigureRenderer(self._panels_or_placeholder(), self.x_range, self._layout, images=self._image_state()) for name, (xs, ys) in self.data.items(): renderer.hist[name] = (list(xs), list(ys)) - for line in self.axvlines: - renderer.add_axvline(line) renderer.render() # sets the line data and autoscales return renderer.fig @@ -1372,8 +1566,6 @@ def _fall_back_to_thread(self, why): renderer = _FigureRenderer(self._panels_or_placeholder(), self.x_range, self._layout, images=self._image_state()) for name, (xs, ys) in self.data.items(): renderer.hist[name] = (list(xs), list(ys)) - for line in self.axvlines: - renderer.add_axvline(line) except Exception as e: # noqa: BLE001 self.mode = "off" warnings.warn( diff --git a/tests/test_axes_api.py b/tests/test_axes_api.py index 915d178..a494818 100644 --- a/tests/test_axes_api.py +++ b/tests/test_axes_api.py @@ -1,19 +1,20 @@ +import numpy as np import pytest from liveplot import LivePlot -from liveplot.liveplot import Panel, _Axis +from liveplot.liveplot import Panel, _Axis, _Line, _parse_fmt def test_addressing_by_metric_and_by_panel(): p = LivePlot("loss | acc", "lr") p.log(0, loss=1.0, acc=0.5, lr=1e-3) - assert [type(x) for x in p.panels] == [Panel, Panel] and p.panels[0].metrics == ["loss", "acc"] + assert [type(x) for x in p.axes] == [Panel, Panel] and p.axes[0].metrics == ["loss", "acc"] left, right, lr = p["loss"], p["acc"], p["lr"] - assert isinstance(left, _Axis) and not left._right and right._right and left.panel.spec is p.panels[0].spec - assert right.metrics == ["acc"] and lr.panel.spec is p.panels[1].spec - assert repr(p.panels[0]) == "Panel(0: loss | acc)" + assert isinstance(left, _Axis) and not left._right and right._right and left.panel.spec is p.axes[0].spec + assert right.metrics == ["acc"] and lr.panel.spec is p.axes[1].spec + assert repr(p.axes[0]) == "Panel(0: loss | acc)" with pytest.raises(AssertionError): - p.panels[1].right # no metrics after '|' + p.axes[1].right # no metrics after '|' def test_matplotlib_named_setters_go_to_the_right_axis(): @@ -24,63 +25,171 @@ def test_matplotlib_named_setters_go_to_the_right_axis(): p["acc"].set_ylim(0, 1) # matplotlib's two-argument form p["loss"].set_ylim((0.0, 2.0)) # ... and the tuple form p["acc"].set_yscale("log") - p.panels[0].set_title("training") - p.panels[0].set_xlabel("examples") - p.panels[0].set_xlim(0, 500) - spec = p.panels[0].spec + p.axes[0].set_title("training") + p.axes[0].set_xlabel("examples") + p.axes[0].set_xlim(0, 500) + spec = p.axes[0].spec assert spec["ylabel"] == "cross-entropy" and spec["ylabel2"] == "test accuracy" assert spec["ylim"] == (0.0, 2.0) and spec["ylim2"] == (0.0, 1.0) and spec["yscale2"] == "log" and spec["yscale"] == "linear" assert spec["title"] == "training" and spec["xlabel"] == "examples" and spec["xlim"] == (0.0, 500.0) p["acc"].set_title("via the axis, like a matplotlib Axes") # panel-level setter reachable from an axis assert spec["title"] == "via the axis, like a matplotlib Axes" - p.panels[0].set_ylabel("left again") # y-setters on a panel act on its left axis + p.axes[0].set_ylabel("left again") # y-setters on a panel act on its left axis assert spec["ylabel"] == "left again" - with pytest.raises(AssertionError): + + +def test_limits_take_matplotlibs_forms_including_one_sided(): + p = LivePlot("loss | acc") + p.log(0, loss=1.0, acc=0.5) + p["loss"].set_ylim(bottom=0) + p["acc"].set_ylim(top=1) + p.axes[0].set_xlim(left=10) + spec = p.axes[0].spec + assert spec["ylim"] == (0.0, None) and spec["ylim2"] == (None, 1.0) and spec["xlim"] == (10.0, None) + p["loss"].set_ylim(ymin=0.5, ymax=2) # matplotlib's aliases + assert spec["ylim"] == (0.5, 2.0) + p["loss"].set_ylim() # nothing given: back to autoscaling + assert spec["ylim"] is None + with pytest.raises(AssertionError, match="once"): + p["loss"].set_ylim(0, ymin=0) + + +def test_one_sided_limit_pins_one_end_and_autoscales_the_other(): + p = LivePlot("loss | acc", progress=False) + for step in range(10): + p.log(step, loss=5.0 - step / 5, acc=0.2 + step / 50) + p["loss"].set_ylim(bottom=0) + p["acc"].set_ylim(top=1) + fig = p.figure() + ax, ax2 = fig.axes[0], fig.axes[1] + assert ax.get_ylim()[0] == 0 and 5.0 <= ax.get_ylim()[1] < 6, "bottom pinned, top follows the data" + assert ax2.get_ylim()[1] == 1 and 0.1 < ax2.get_ylim()[0] <= 0.2, "top pinned, bottom follows the data" + + +def test_setters_pass_matplotlib_kwargs_through_and_check_them_early(): + p = LivePlot("loss | acc", progress=False) + for step in (1, 10, 100): # positive x, for the log x-axis below + p.log(step, loss=1.0 / step, acc=0.5) + p.axes[0].set_title("training", loc="left", fontsize=9) + p["acc"].set_ylabel("accuracy", color="tab:orange") + p["loss"].set_yscale("symlog", linthresh=0.1) + p.axes[0].set_xscale("log") + fig = p.figure() + ax, ax2 = fig.axes[0], fig.axes[1] + assert ax.get_title(loc="left") == "training" and ax.title.get_text() == "" + assert ax2.yaxis.label.get_color() == "tab:orange" + assert ax.get_yscale() == "symlog" and ax.get_xscale() == "log" + # matplotlib's own errors, raised here rather than in the render process + with pytest.raises(ValueError, match="not a valid value for scale"): p["acc"].set_yscale("sqrt") - with pytest.raises(AssertionError): - p["acc"].set_ylim(0) + with pytest.raises(AttributeError, match="fontsizee"): + p.axes[0].set_title("t", fontsizee=3) + with pytest.raises(TypeError): + p.axes[0].legend(locc="upper left") def test_set_kwargs_and_reference_lines(): p = LivePlot("loss | acc") p.log(0, loss=1.0, acc=0.5) p["acc"].set(ylabel="acc", ylim=(0, 1), yscale="log") - p.panels[0].set(title="t", xlabel="x", smooth=0.5) - spec = p.panels[0].spec + p.axes[0].set(title="t", xlabel="x", smooth=0.5) + spec = p.axes[0].spec assert (spec["ylabel2"], spec["ylim2"], spec["yscale2"], spec["title"], spec["xlabel"], spec["smooth"]) == ("acc", (0.0, 1.0), "log", "t", "x", 0.5) - p["loss"].axhline(0.1, "target", color="red") + p["loss"].axhline(0.1, color="red", label="target") p["acc"].axhline(0.9) - p.panels[0].axvline(3, "here", linewidth=2) + p["acc"].axhline(0.5, 0.25, 0.75) # matplotlib's xmin / xmax + p.axes[0].axvline(3, label="here", linewidth=2) p.log(7, loss=0.5) - p.panels[0].axvline(label="now") # x defaults to the current step - assert spec["axhlines"] == [{"color": "red", "y": 0.1, "label": "target"}] and spec["axhlines2"] == [{"y": 0.9, "label": "0.9"}] - assert spec["axvlines"] == [{"linewidth": 2, "x": 3.0, "label": "here"}, {"x": 7.0, "label": "now"}] - assert p.axvlines == [], "panel-level axvline is not a plot-wide one" - p.axhline(0.0, "zero", metric="loss") # the plot-level form with metric= delegates to the axis - assert spec["axhlines"][-1] == {"y": 0.0, "label": "zero"} + p.axes[0].axvline(p.step, label="now") # the current point of the run, spelt out + assert spec["axhlines"] == [{"color": "red", "label": "target", "y": 0.1}] + assert spec["axhlines2"] == [{"y": 0.9}, {"y": 0.5, "xmin": 0.25, "xmax": 0.75}] + assert spec["axvlines"] == [{"label": "here", "linewidth": 2, "x": 3.0}, {"label": "now", "x": 7.0}] + with pytest.raises(ValueError, match="xmin"): + p["loss"].axhline(0.1, "target") # matplotlib's second argument is xmin, not a label -def test_plot_level_setters_apply_to_every_panel_and_later_ones(): +def test_set_all_applies_to_every_panel_and_later_ones(): p = LivePlot("loss", "acc") - p.set(xlabel="examples", yscale="log") - p.set_smooth(0.8) - p.log(0, loss=1.0, acc=0.5, lr=1e-3) # lr's panel is created after the setters were called - for panel in p.panels: + p.set_all(xlabel="examples", yscale="log", smooth=0.8) + p.log(0, loss=1.0, acc=0.5, lr=1e-3) # lr's panel is created after set_all + for panel in p.axes: assert panel.spec["xlabel"] == "examples" and panel.spec["yscale"] == "log" and panel.spec["smooth"] == 0.8 - p.set_ylim(0, 10) - assert all(panel.spec["ylim"] == (0.0, 10.0) for panel in p.panels) + p.set_all(ylim=(0, 10)) + assert all(panel.spec["ylim"] == (0.0, 10.0) for panel in p.axes) with pytest.raises(AssertionError): - p.set_smooth(1.5) + p.set_all(smooth=1.5) + with pytest.raises(AssertionError, match="no set_colour"): + p.set_all(colour="red") + + +def test_the_plot_is_the_figure(): + p = LivePlot("loss", "acc", progress=False) + p.log(0, loss=1.0, acc=0.5) + p.suptitle("run 3", fontsize=14) + p.supxlabel("examples") + fig = p.figure() + assert fig._suptitle.get_text() == "run 3" and fig._suptitle.get_fontsize() == 14 + assert fig._supxlabel.get_text() == "examples" + assert [ax.get_title() for ax in fig.axes if ax.get_visible()] == ["loss", "acc"], "panel titles untouched" + for name in ("set_title", "set_xlabel", "set_ylim", "axhline", "axvline", "panels"): + assert not hasattr(p, name), f"{name} would act on every panel; Figure has no such method" def test_zero_config_then_configure(): p = LivePlot() - p.set_title("my run") # before any panel exists + p.set_all(title="my run") # before any panel exists p.log(0, loss=1.0) - assert p.panels[0].spec["title"] == "my run" + assert p.axes[0].spec["title"] == "my run" p["val_loss"].set_ylabel("held-out") # addressing a metric not logged yet creates its panel - assert p.panels[0].metrics == ["loss", "val_loss"], "no layout given: the new metric joins the shared panel" - assert p.panels[0].spec["ylabel"] == "held-out" + assert p.axes[0].metrics == ["loss", "val_loss"], "no layout given: the new metric joins the shared panel" + assert p.axes[0].spec["ylabel"] == "held-out" + + +def test_plot_takes_fmt_strings_and_line2d_kwargs(): + assert _parse_fmt("r--") == {"color": "r", "linestyle": "--"} + assert _parse_fmt("o") == {"marker": "o", "linestyle": "None"}, "a marker alone means no line, as in matplotlib" + assert _parse_fmt("C2-.") == {"color": "C2", "linestyle": "-."} + assert _parse_fmt("tab:orange") == {"color": "tab:orange"} and _parse_fmt("#1f77b4") == {"color": "#1f77b4"} + assert _parse_fmt("lossG") is None + plot, ax = LivePlot.subplots(progress=False) + lines = ax.plot("loss", "r--", label="train loss", alpha=0.5) + assert isinstance(lines, list) and len(lines) == 1 and isinstance(lines[0], _Line) + (line,) = lines + assert line.get_label() == "train loss" + line.set_linewidth(3) + line.set(marker="o") + assert ax.spec["styles"]["loss"] == {"color": "r", "linestyle": "--", "label": "train loss", "alpha": 0.5, + "linewidth": 3, "marker": "o"} + ax.lines[0].set_color("k") # Axes.lines + for step in range(3): + plot.log(step, loss=1.0 / (step + 1)) + drawn = plot.figure().axes[0].get_lines()[0] + assert (drawn.get_color(), drawn.get_linestyle(), drawn.get_linewidth(), drawn.get_alpha(), drawn.get_marker()) == ("k", "--", 3, 0.5, "o") + assert [t.get_text() for t in plot.figure().axes[0].get_legend().get_texts()] == ["train loss"] + with pytest.raises(AttributeError, match="colr"): + ax.plot("loss", colr="r") + with pytest.raises(AssertionError, match="log()"): + ax.plot("loss", xdata=[1, 2]) + + +def test_two_names_are_matplotlibs_x_and_y_so_they_raise(): + plot, ax = LivePlot.subplots(progress=False) + with pytest.raises(TypeError, match=r"one metric per call.*'lossG' against 'lossD'.*ax.plot\('lossD'\); ax.plot\('lossG'\)"): + ax.plot("lossD", "lossG") + with pytest.raises(TypeError, match="one metric per call"): + ax.plot("lossD", "lossG", "r--") + with pytest.raises(AssertionError, match="not data"): + ax.plot(np.arange(3)) + + +def test_underscore_labels_stay_out_of_the_legend(): + p = LivePlot("loss | acc", progress=False) + p.log(0, loss=1.0, acc=0.5) + p["acc"].panel.right.plot("acc", label="_hidden") + p["loss"].axhline(0.5) # unlabelled: no entry, as in matplotlib + p["loss"].axhline(0.2, label="target") + fig = p.figure() + assert [t.get_text() for t in fig.axes[0].get_legend().get_texts()] == ["loss", "target"] def test_figure_reflects_setters(): @@ -89,9 +198,9 @@ def test_figure_reflects_setters(): p.log(step, loss=1.0 / (step + 1), acc=step / 5) p["acc"].set_ylim(0, 1) p["acc"].set_ylabel("accuracy") - p.panels[0].set_title("hello") - p.panels[0].axvline(2, "two") + p.axes[0].set_title("hello") + p.axes[0].axvline(2, label="two") fig = p.figure() ax, ax2 = fig.axes[0], fig.axes[1] assert ax.get_title() == "hello" and ax2.get_ylabel() == "accuracy" and ax2.get_ylim() == (0.0, 1.0) - assert any(t.get_text().strip() == "two" for t in ax.texts) + assert "two" in [t.get_text() for t in ax.get_legend().get_texts()], "a labelled axvline is in the legend" diff --git a/tests/test_figure_export.py b/tests/test_figure_export.py index 68ce155..24c339e 100644 --- a/tests/test_figure_export.py +++ b/tests/test_figure_export.py @@ -5,17 +5,16 @@ def test_figure_reflects_current_state(tmp_path): p = LivePlot("loss | acc", {"metrics": ["lr"], "smooth": 0.9}, total=100, unit="examples", unit_scale=10, progress=False) for step in range(20): p.log(step * 10, loss=1.0 / (step + 1), acc=step / 20, lr=1e-3) - p.axhline(0.2, "target", metric="loss") - p.axvline(100, "halfway") + p["loss"].axhline(0.2, label="target") + p.axes[0].axvline(100, label="halfway") fig = p.figure() axes = [ax for ax in fig.axes if ax.get_visible()] assert len(axes) == 3 # panel 1 left + right axis, panel 2 ax_loss = axes[0] labels = [t.get_text() for t in ax_loss.get_legend().get_texts()] - assert labels == ["loss", "acc", "target"] + assert labels == ["loss", "acc", "target", "halfway"] assert ax_loss.get_xlim() == (0, 1000) and ax_loss.get_xlabel() == "examples" assert list(ax_loss.get_lines()[0].get_xdata()) == [step * 10 for step in range(20)] - assert any(t.get_text().strip() == "halfway" for t in ax_loss.texts) out = tmp_path / "run.png" fig.savefig(out) assert out.stat().st_size > 1000 diff --git a/tests/test_liveplot.py b/tests/test_liveplot.py index 5f7de7c..a0f64fb 100644 --- a/tests/test_liveplot.py +++ b/tests/test_liveplot.py @@ -82,7 +82,7 @@ def test_x_axis_follows_tqdm_counting(): r.log(loss=1.0) xs.append(r.step) assert xs == [0, 128, 256] and r.data["loss"][0] == xs and r.x_range == (0, 384) - assert r._layout[-1] == "examples", "default x label is the unit" + assert r._layout[5] == "examples", "default x label is the unit" s = LivePlot(range(3), progress=False) # explicit step is per call, like wandb's step= for i in s: diff --git a/tests/test_reference_lines.py b/tests/test_reference_lines.py index ec46ca3..c4c4dd9 100644 --- a/tests/test_reference_lines.py +++ b/tests/test_reference_lines.py @@ -26,18 +26,18 @@ def test_normalise_axhlines(): def test_renderer_draws_reference_lines_with_matplotlib_kwargs(): panels = [_normalise_panel({"metrics": ["loss"], "secondary": ["acc"], "axhlines": [dict(y=2.3, label="uniform", color="red", linestyle="-")], - "axhlines2": {"solved": 0.95}})] + "axhlines2": {"solved": 0.95}, + "axvlines": [{"x": 5, "label": "lr drop", "color": "blue"}]}), + _normalise_panel("lr")] r = _FigureRenderer(panels, xlim=None, layout=LAYOUT) ax, ax2 = r.lines["loss"].axes, r.lines["acc"].axes - assert [t.get_text() for t in ax.get_legend().get_texts()] == ["loss", "acc", "uniform", "solved"] + assert [t.get_text() for t in ax.get_legend().get_texts()] == ["loss", "acc", "uniform", "lr drop", "solved"] ref = ax.get_lines()[1] assert ref.get_color() == "red" and ref.get_linestyle() == "-", "kwargs reach the matplotlib artist" assert ax2.get_lines()[1].get_linestyle() == "--", "default style when no kwargs given" - r.add_axvline({"x": 5, "label": "lr drop", "color": "blue"}) - assert len(ax.get_lines()) == 3 and ax.get_lines()[2].get_color() == "blue" and ax.get_lines()[2].get_linestyle() == ":" - assert any(t.get_text().strip() == "lr drop" for t in ax.texts) - r.set_layout(panels + [_normalise_panel("lr")]) # re-layout keeps the vertical line, on the new panel too - assert all(any(line.get_linestyle() == ":" for line in a.get_lines()) for a in r.axes) + vline = ax.get_lines()[2] + assert vline.get_color() == "blue" and vline.get_linestyle() == ":", "axvline: dotted unless told otherwise" + assert not any(line.get_linestyle() == ":" for line in r.axes[1].get_lines()), "an axvline is on its own panel only" for step in range(10): r.add(step, {"loss": 3.0 - step / 5, "acc": step / 10}) assert r.render()[:8] == PNG @@ -47,17 +47,18 @@ def test_renderer_draws_reference_lines_with_matplotlib_kwargs(): def test_axhline_and_axvline_off_mode(): p = LivePlot("loss | acc") p.log(0, loss=1.0, acc=0.5) - p.axhline(0.2, "target", metric="loss", color="green") - p.axhline(0.9, metric="acc") # label defaults to the value - p.axhline(0.0) # no metric: left axis of every panel - assert p._specs[0]["axhlines"] == [{"color": "green", "y": 0.2, "label": "target"}, {"y": 0.0, "label": "0"}] - assert p._specs[0]["axhlines2"] == [{"y": 0.9, "label": "0.9"}] - p.axhline(1e-3, "final lr", metric="lr") # metric not logged yet: creates its panel - assert p._specs[1]["metrics"] == ["lr"] and p._specs[1]["axhlines"] == [{"y": 0.001, "label": "final lr"}] + p["loss"].axhline(0.2, label="target", color="green") + p["acc"].axhline(0.9) # no label: drawn, but not in the legend + p.axes[0].axhline() # matplotlib's default y=0, on the panel's left axis + assert p._specs[0]["axhlines"] == [{"label": "target", "color": "green", "y": 0.2}, {"y": 0.0}] + assert p._specs[0]["axhlines2"] == [{"y": 0.9}] + p["lr"].axhline(1e-3, label="final lr") # metric not logged yet: creates its panel + assert p._specs[1]["metrics"] == ["lr"] and p._specs[1]["axhlines"] == [{"label": "final lr", "y": 0.001}] p.log(7, loss=0.5) - p.axvline(label="lr drop") # x defaults to the current step - p.axvline(9, "later", linewidth=2) - assert p.axvlines == [{"x": 7.0, "label": "lr drop"}, {"linewidth": 2, "x": 9.0, "label": "later"}] + p.axes[0].axvline(p.step, label="lr drop") + p.axes[1].axvline(9, linewidth=2) + assert p._specs[0]["axvlines"] == [{"label": "lr drop", "x": 7.0}] + assert p._specs[1]["axvlines"] == [{"linewidth": 2, "x": 9.0}] class _FakeHandle: @@ -79,7 +80,7 @@ def test_process_mode_reference_lines(monkeypatch): for step in range(40): p.log(step, loss=11 - step / 8) if step == 20: - p.axvline(label="halfway") - p.axhline(7.35, "unigram", metric="loss") + p.axes[0].axvline(p.step, label="halfway") + p["loss"].axhline(7.35, label="unigram") time.sleep(0.03) assert h.frames and all(f[:8] == PNG for f in h.frames) and not p._proc.is_alive() diff --git a/tests/test_robustness.py b/tests/test_robustness.py index ef7fe71..01ae2d0 100644 --- a/tests/test_robustness.py +++ b/tests/test_robustness.py @@ -35,18 +35,18 @@ def test_a_renderer_that_cannot_be_built_does_not_break_the_loop(monkeypatch): def test_set_title_survives_a_newly_discovered_metric(): p = LivePlot(progress=False) - p.set_title("my run") + p.set_all(title="my run") p.log(0, loss=1.0) p.log(1, acc=0.5) # joins the shared panel; used to reset the title to "loss / acc" - assert p.panels[0].spec["title"] == "my run" - p.panels[0].set_title("chosen later") + assert p.axes[0].spec["title"] == "my run" + p.axes[0].set_title("chosen later") p.log(2, lr=1e-3) - assert p.panels[0].spec["title"] == "chosen later" + assert p.axes[0].spec["title"] == "chosen later" q = LivePlot(progress=False) # a title nobody chose still tracks the metrics q.log(0, loss=1.0) - assert q.panels[0].spec["title"] == "loss" + assert q.axes[0].spec["title"] == "loss" q.log(1, acc=0.5) - assert q.panels[0].spec["title"] == "loss / acc" + assert q.axes[0].spec["title"] == "loss / acc" def test_tqdm_kwargs_reach_the_bar_without_colliding(): diff --git a/tests/test_subplots.py b/tests/test_subplots.py index a0fb4e8..cab5b48 100644 --- a/tests/test_subplots.py +++ b/tests/test_subplots.py @@ -6,7 +6,7 @@ import pytest from liveplot import LivePlot -from liveplot.liveplot import Panel, _FigureRenderer, _normalise_panel, _PanelGrid +from liveplot.liveplot import Panel, _FigureRenderer, _normalise_panel LAYOUT = (3, None, None, (4, 3), 50, "step") PNG = b"\x89PNG\r\n\x1a\n" @@ -20,15 +20,16 @@ def test_subplots_squeezes_like_matplotlib(): plot, ax = LivePlot.subplots(progress=False) assert isinstance(ax, Panel), "1x1 -> the panel itself" plot, axs = LivePlot.subplots(1, 3, progress=False) - assert isinstance(axs, list) and len(axs) == 3, "a single row -> a flat list, so it unpacks" + assert isinstance(axs, np.ndarray) and axs.shape == (3,), "a single row -> 1-d, so it unpacks" plot, axs = LivePlot.subplots(3, 1, progress=False) - assert isinstance(axs, list) and len(axs) == 3 + assert axs.shape == (3,) plot, axs = LivePlot.subplots(2, 2, progress=False) - assert isinstance(axs, _PanelGrid) and axs.shape == (2, 2) and len(axs.flat) == 4 + assert isinstance(axs, np.ndarray) and axs.shape == (2, 2), "a numpy array of panels, as matplotlib returns" + assert len(axs.flatten()) == len(axs.ravel()) == len(list(axs.flat)) == 4 assert axs[0, 1].spec is axs[0][1].spec, "matplotlib's two spellings agree" - assert axs[1, 0].spec is plot.panels[2].spec, "row-major, as in matplotlib" + assert axs[1, 0].spec is plot.axes[2].spec, "row-major, as in matplotlib" plot, grid = LivePlot.subplots(1, 2, squeeze=False, progress=False) - assert isinstance(grid, _PanelGrid) and grid.shape == (1, 2) + assert grid.shape == (1, 2) with pytest.raises(AssertionError, match="1x1 grid"): LivePlot.subplots(0, 2, progress=False) @@ -41,8 +42,10 @@ def test_figsize_is_the_whole_figure_as_matplotlib_means_it(): def test_plot_assigns_metrics_and_twinx_gives_the_right_axis(): plot, (ax_loss, ax_lr) = LivePlot.subplots(1, 2, progress=False) - returned = ax_loss.plot("lossD", "lossG") - assert returned.plot("lossD") is returned, "chainable, and a repeat is not a duplicate" + returned = ax_loss.plot("lossD") + assert isinstance(returned, list) and len(returned) == 1, "a one-element list of lines, as matplotlib returns" + ax_loss.plot("lossG") + ax_loss.plot("lossD") # a repeat is not a duplicate ax_loss.twinx().plot("D(x)") ax_lr.plot("lr") assert ax_loss.spec["metrics"] == ["lossD", "lossG"] and ax_loss.spec["secondary"] == ["D(x)"] @@ -50,8 +53,10 @@ def test_plot_assigns_metrics_and_twinx_gives_the_right_axis(): plot.log(0, lossD=1.0, lossG=0.5, lr=1e-3, **{"D(x)": 0.6}) assert len(plot._specs) == 2, "subplots() fixed the grid: no third panel appeared" assert plot["D(x)"]._right and plot["lossD"]._right is False - with pytest.raises(AssertionError, match="metric names, not data"): + with pytest.raises(AssertionError, match="not data"): ax_lr.plot([1, 2, 3]) + with pytest.raises(ValueError, match="already drawn"): + ax_lr.plot("lossD") def test_a_metric_nobody_declared_joins_a_panel_rather_than_growing_the_grid(): @@ -121,15 +126,6 @@ def test_renderer_draws_images_and_keeps_them_through_a_relayout(): assert len(r.hist["loss"][0]) == 5, "and the curve history survives as before" -def test_axvline_skips_image_panels(): - panels = [_normalise_panel({"metrics": ["loss"]}), _normalise_panel({"kind": "image"})] - r = _FigureRenderer(panels, xlim=None, layout=LAYOUT) - r.add_image(1, np.zeros((8, 8), np.uint8)) - r.add_axvline({"x": 2, "label": "lr drop"}) - assert any(line.get_linestyle() == ":" for line in r.axes[0].get_lines()) - assert not r.image_axes[1].get_lines(), "a vertical line across a picture means nothing" - - def test_figure_includes_the_image(): plot, (ax_loss, ax_img) = LivePlot.subplots(1, 2, total=20, progress=False) ax_loss.plot("loss") @@ -182,7 +178,8 @@ def _colour_images_are_safe_here() -> bool: def test_process_mode_curves_beside_images(fake_notebook): """The DCGAN shape: curves every step, samples every so often, one figure, one output cell.""" plot, (ax_loss, ax_img) = LivePlot.subplots(1, 2, total=200, refresh_seconds=0.1, figsize=(8, 3), dpi=40) - ax_loss.plot("lossD", "lossG") + ax_loss.plot("lossD") + ax_loss.plot("lossG") ax_img.set_title("generator samples") assert plot.mode == "process" t0 = time.monotonic() @@ -206,7 +203,8 @@ def test_process_mode_curves_beside_images(fake_notebook): def test_width_ratios_and_legend_placement(): """matplotlib's own names: subplots(width_ratios=...) and ax.legend(**kwargs).""" plot, (ax_loss, ax_img) = LivePlot.subplots(1, 2, width_ratios=(1, 2), progress=False) - ax_loss.plot("lossD", "lossG") + ax_loss.plot("lossD") + ax_loss.plot("lossG") ax_loss.twinx().plot("D(x)") ax_loss.legend(loc="upper center", ncols=3) ax_img.imshow(rng_images()) @@ -222,6 +220,35 @@ def test_width_ratios_and_legend_placement(): LivePlot.subplots(1, 2, width_ratios=(1, 2, 3), progress=False) +def test_sharex_gridspec_kw_and_subplot_kw_are_matplotlibs(): + plot, axs = LivePlot.subplots(2, 1, sharex=True, gridspec_kw={"height_ratios": [2, 1]}, + subplot_kw={"xlabel": "examples"}, progress=False) + axs[0].plot("loss") + axs[1].plot("lr") + plot.log(0, loss=1.0, lr=0.1) + plot.log(10, loss=0.5, lr=0.05) + a, b = [ax for ax in plot.figure().axes if ax.get_visible()] + assert a.get_shared_x_axes().joined(a, b) + assert a.get_subplotspec().get_gridspec().get_height_ratios() == [2, 1] + assert a.get_xlabel() == b.get_xlabel() == "examples" + with pytest.raises(AssertionError, match="both as parameter"): + LivePlot.subplots(1, 2, width_ratios=(1, 2), gridspec_kw={"width_ratios": [1, 1]}, progress=False) + with pytest.raises(AssertionError, match="sharey"): + LivePlot.subplots(1, 2, sharey="columns", progress=False) + + +def test_imshow_passes_matplotlib_kwargs(): + plot, ax = LivePlot.subplots(progress=False) + ax.imshow(rng_images(), interpolation="bilinear") + assert plot.figure().axes[0].images[0].get_interpolation() == "bilinear" + ax.imshow(rng_images()) # back to liveplot's default, crisp pixels + assert plot.figure().axes[0].images[0].get_interpolation() == "nearest" + with pytest.raises(ValueError): + ax.imshow(rng_images(), interpolation="blurry") + with pytest.raises(AssertionError, match="scales the pixels itself"): + ax.imshow(rng_images(), norm="log") + + def test_update_drives_one_bar_across_epochs(): """tqdm's manual mode: plot.update() advances the count (the x-axis) and one bar for the run.""" pytest.importorskip("tqdm") From 1b4dd60c96554b1b6ef3cd4b7f83c861c2712ccd Mon Sep 17 00:00:00 2001 From: David Quarel Date: Tue, 29 Sep 2026 21:27:43 +0000 Subject: [PATCH 2/2] Easier to use: one Axes type, string layouts, savefig, quieter legends, 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 --- README.md | 25 +-- examples/demo.py | 3 +- liveplot/liveplot.py | 384 +++++++++++++++------------------- tests/conftest.py | 9 + tests/test_axes_api.py | 78 +++++-- tests/test_figure_export.py | 9 +- tests/test_liveplot.py | 3 +- tests/test_reference_lines.py | 21 +- tests/test_robustness.py | 3 +- tests/test_smoothing.py | 6 +- tests/test_subplots.py | 35 ++-- 11 files changed, 296 insertions(+), 280 deletions(-) create mode 100644 tests/conftest.py diff --git a/README.md b/README.md index a0a4d03..8d66032 100644 --- a/README.md +++ b/README.md @@ -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: @@ -79,15 +79,15 @@ 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 -The plot is the figure and its panels are the Axes, configured with matplotlib's own names and signatures, 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") @@ -102,18 +102,16 @@ plot.axes[0].set_xlim(0, 10_000) plot.axes[0].set_xscale("log") plot.suptitle("run 3") # Figure.suptitle / supxlabel / supylabel -plot.set_all(xlabel="examples", smooth=0.9) # Panel.set on every panel, including ones created later +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.axes[i].left` / `.right` name the two axes explicitly. 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)`; the panel's setters are reachable from an axis as they would be on an `Axes`. 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. +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 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. -The dict form, `{"metrics": ["acc"], "ylim": (0, 1), ...}`, is still accepted in the layout list but the setters are the intended way. +`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.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. +`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 @@ -159,8 +157,11 @@ matplotlib's arguments -- `sharex`, `sharey`, `squeeze`, `width_ratios`, `height 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, diff --git a/examples/demo.py b/examples/demo.py index 399595d..38bd09f 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -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: diff --git a/liveplot/liveplot.py b/liveplot/liveplot.py index d3738af..f069b6b 100644 --- a/liveplot/liveplot.py +++ b/liveplot/liveplot.py @@ -41,13 +41,8 @@ Each string is one panel. Names separated by spaces share the left y-axis; a `|` puts the names after it on a right-hand y-axis. Metrics you log that no string -mentions get a panel of their own. Every panel has a legend. For labels or fixed -ranges, use a dict instead of a string: - - {"metrics": ["acc"], "ylim": (0, 1), "ylabel": "test accuracy", "xlabel": "epoch"} - -(the dict form; the setters below are the nicer way). The plot is the figure and its -panels are the Axes, configured with matplotlib's own names and signatures, before +mentions get a panel of their own. 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: plot["acc"].set_ylim(0, 1) # the y-axis holding a metric (left or right) @@ -56,13 +51,15 @@ plot["loss"].axhline(0.1, color="red", label="target") # a label puts it in the legend plot.axes[0].set_title("training") # plot.axes, like Figure.axes plot.axes[0].axvline(plot.step, label="lr drop") - plot.axes[0].right.set_yscale("log") # .left / .right are the panel's y-axes + plot.axes[0].twinx().set_yscale("log") # the panel's right-hand y-axis, as in matplotlib plot.suptitle("run 3") # Figure.suptitle / supxlabel / supylabel plot.set_all(xlabel="examples", smooth=0.9) # every panel, including ones made later -Available on an axis: plot, set_ylabel, set_ylim, set_yscale, axhline, set(**kw), -lines; on a panel: set_title, set_xlabel, set_xlim, set_xscale, axvline, legend, -twinx, imshow, set_smooth, set(**kw), plus the left axis's setters. Arguments are +One `Axes` type, as in matplotlib: `plot.axes[i]` and `subplots` give a panel's +main axis, `twinx()` its right-hand one, `plot["acc"]` whichever holds `acc`. Each +has plot, imshow, set_ylabel, set_ylim, set_yscale, axhline and lines for itself, +and set_title, set_xlabel, set_xlim, set_xscale, axvline, legend and set_smooth for +the panel it shares with its twin; set(**kw) takes any of them. Arguments are matplotlib's (text kwargs, scale kwargs, one-sided limits, Line2D kwargs on reference lines), and each call is tried on a scratch matplotlib Axes first, so a mistake raises matplotlib's own error where it was made. Smoothing is wandb's @@ -97,7 +94,7 @@ How it works: the training thread only appends numbers (~40 us per `log`). A separate *render process* owns the matplotlib figure, redraws it at most once per -`refresh_seconds` (default 1.0; points arriving in between are batched into the +`refresh_seconds` (default 0.2; points arriving in between are batched into the next frame; 0 means redraw on every arrival, as fast as rendering allows), and sends back PNG bytes that get swapped into a fixed output cell. The output is a plain image, so it behaves the same in Jupyter, Colab, VS Code and Cursor: no widgets, no CDN, no JavaScript. The progress bar is tqdm @@ -113,7 +110,8 @@ leaves a frozen plot with `plot.data` intact and no stray process. Outside a notebook nothing is drawn; `plot.data` still collects everything. -`plot.figure()` returns a matplotlib Figure of the current state, for saving or tweaking. +`plot.savefig("run.png")` saves the plot as it stands, and `plot.snapshot()` returns it +as a matplotlib Figure to tweak; both work in or out of a notebook. Recording: `LivePlot(..., record=True)` keeps every rendered frame in `plot.frames` (as PNG bytes with timestamps) and `plot.save_gif("run.gif")` stitches them into an @@ -147,27 +145,6 @@ _REF_LINE_STYLE = {"linestyle": "--", "linewidth": 1, "color": "0.45"} # defaults for axhline / axvline artists -def _normalise_axhlines(spec) -> list: - """ - Reference lines for a panel, in any of these forms, -> a list of matplotlib `Axes.axhline` kwargs: - {"uniform": 10.8, "unigram": 7.35} label -> y - [10.8, 7.35] y values (labelled with the number) - [dict(y=10.8, label="uniform", color="red")] full matplotlib kwargs per line - """ - if not spec: - return [] - if isinstance(spec, dict): - return [{"y": float(y), "label": str(label)} for label, y in spec.items()] - out = [] - for item in spec: - if isinstance(item, dict): - assert "y" in item, f"axhline needs a y: {item!r}" - out.append({**item, "y": float(item["y"]), "label": str(item.get("label", f"{float(item['y']):g}"))}) - else: - out.append({"y": float(item), "label": f"{float(item):g}"}) - return out - - # --------------------------------------------------------------------------- panel specs @@ -185,7 +162,10 @@ def _check_smooth(weight): def _normalise_panel(spec, allow_empty: bool = False) -> dict: - """`allow_empty` is for the panels `LivePlot.subplots` hands out before anything is drawn on them.""" + """ + A panel string, or the dict the setters build, -> the full panel spec the renderer draws from. + `allow_empty` is for the panels `LivePlot.subplots` hands out before anything is drawn on them. + """ if isinstance(spec, str): spec = _parse_panel_string(spec) unknown = set(spec) - _PANEL_KEYS @@ -207,8 +187,8 @@ def _normalise_panel(spec, allow_empty: bool = False) -> dict: "xlim": spec.get("xlim"), "ylim": spec.get("ylim"), "ylim2": spec.get("ylim2"), - "axhlines": _normalise_axhlines(spec.get("axhlines")), # reference lines on the left axis (see _normalise_axhlines) - "axhlines2": _normalise_axhlines(spec.get("axhlines2")), # ... and on the right axis + "axhlines": [dict(kw) for kw in spec.get("axhlines") or []], # horizontal lines on the left axis (axhline kwargs) + "axhlines2": [dict(kw) for kw in spec.get("axhlines2") or []], # ... and on the right axis "axvlines": [dict(kw) for kw in spec.get("axvlines") or []], # vertical lines on this panel only (axvline kwargs) "smooth": _check_smooth(spec.get("smooth")), # TWEMA weight in [0, 1); None = plot-wide default; 0 = off "xscale": spec.get("xscale", "linear"), # any matplotlib scale name: "linear", "log", "symlog", ... @@ -216,7 +196,8 @@ def _normalise_panel(spec, allow_empty: bool = False) -> dict: "yscale2": spec.get("yscale2", "linear"), # ... right axis "kind": kind, # "curve" (metric lines) or "image" (one picture, overwritten in place) "cmap": spec.get("cmap", "gray"), # image panels only; matplotlib ignores it for RGB data - "legend": dict(spec.get("legend") or {}), # Axes.legend kwargs (loc, ncols, bbox_to_anchor, ...) + # Axes.legend kwargs (loc, ncols, bbox_to_anchor, ...) once legend() is called; None = not asked for + "legend": None if spec.get("legend") is None else dict(spec["legend"]), "styles": {k: dict(v) for k, v in (spec.get("styles") or {}).items()}, # metric -> Line2D kwargs, from ax.plot "kw": {k: dict(v) for k, v in (spec.get("kw") or {}).items()}, # extra matplotlib kwargs for the setters: # "title", "xlabel", "ylabel", "ylabel2" (Text kwargs), "xscale", "yscale", "yscale2", "imshow" @@ -299,6 +280,8 @@ def set_layout(self, panels): gridspec_kw = dict(figure_kw.get("gridspec_kw") or {}) n = len(panels) rows, cols = _grid_shape(n, max_cols, rows_opt, cols_opt) + if figure_kw.get("figsize"): # the whole figure, as matplotlib means it: split it over the grid + cell_size = (figure_kw["figsize"][0] / cols, figure_kw["figsize"][1] / rows) self.panels = panels fig_width = cell_size[0] * cols # One row with pictures in it, and no width_ratios chosen: size each image panel to its picture @@ -379,9 +362,11 @@ def set_layout(self, panels): handles, labels = handles + handles2, labels + labels2 curves = set(map(id, self.lines.values())) order = sorted(range(len(handles)), key=lambda k: id(handles[k]) not in curves) - if handles: # (nothing logged yet, or the "waiting for data" placeholder) + # A legend when there is something to tell apart, or when legend() asked for one: a panel + # with one curve is named by its title already. + if len(handles) >= 2 or (handles and panel["legend"] is not None): ax.legend([handles[k] for k in order], [labels[k] for k in order], - **{"loc": "best", "fontsize": 8, **panel["legend"]}) + **{"loc": "best", "fontsize": 8, **(panel["legend"] or {})}) for ax in axes[n:]: ax.set_visible(False) self.axes = list(axes[:n]) @@ -701,40 +686,59 @@ def _text_kw(kwargs: dict, **named) -> dict: return {**{k: v for k, v in named.items() if v is not None}, **kwargs} -class _Axis: +class Axes: """ - One y-axis of a panel (left, or the right-hand `secondary` one), with matplotlib's `Axes` names: - plot / set_ylabel / set_ylim / set_yscale / axhline / set(**kwargs). Panel-level setters (title, - xlabel, xlim, xscale, axvline, legend, smooth) are available here too, as they are on a matplotlib - Axes. + One y-axis of a panel, used as a matplotlib Axes: what `plot.axes[i]` and `LivePlot.subplots` + hand out (a panel's main, left-hand axis), what `ax.twinx()` returns (its right-hand one), and + what `plot["acc"]` finds (whichever holds `acc`). As with a matplotlib Axes and its twin, the two + y-axes of a panel have their own curves, y-label, y-limits, y-scale and horizontal lines, and + share the panel's title, x-axis, vertical lines, legend and smoothing: setting any of those on + either one sets it for the panel. """ - def __init__(self, plot, index: int, right: bool): + def __init__(self, plot, index: int, right: bool = False): self._plot, self._index, self._right = plot, index, right @property - def panel(self): - return Panel(self._plot, self._index) + def spec(self) -> dict: + return self._plot._specs[self._index] @property def metrics(self) -> list: - return list(self._plot._specs[self._index]["secondary" if self._right else "metrics"]) + """The metrics drawn on this y-axis.""" + return list(self.spec["secondary" if self._right else "metrics"]) @property def lines(self) -> list: - """Like `Axes.lines`: a handle on each metric's line on this axis, for restyling.""" + """Like `Axes.lines`: a handle on each metric's line on this y-axis, for restyling.""" return [_Line(self._plot, self._index, name) for name in self.metrics] + def twinx(self) -> "Axes": + """Like `Axes.twinx`: the panel's right-hand y-axis, sharing its x-axis.""" + return Axes(self._plot, self._index, right=True) + def _key(self, key): return key + ("2" if self._right else "") def _set(self, key, value, kw=None): - spec = self._plot._specs[self._index] - spec[self._key(key)] = value + """Store a panel setting; `key` already names the side for a y-axis setting (see `_key`).""" + self.spec[key] = value if kw is not None: - spec["kw"][self._key(key)] = kw + self.spec["kw"][key] = kw self._plot._send_layout() + def __eq__(self, other): + return isinstance(other, Axes) and (other._plot, other._index, other._right) == (self._plot, self._index, self._right) + + def __hash__(self): + return hash((id(self._plot), self._index, self._right)) + + def __repr__(self): + side = " (right)" if self._right else "" + return f"Axes({self._index}{side}: {' '.join(self.metrics)})" + + # -- drawing ------------------------------------------------------------------- + def plot(self, *args, **kwargs) -> list: """ Like `Axes.plot`, with a metric's name where matplotlib takes data: `ax.plot("loss")` draws the @@ -762,7 +766,7 @@ def plot(self, *args, **kwargs) -> list: ) style = {**(style or {}), **kwargs} _check_line_style(style) - spec = self._plot._specs[self._index] + spec = self.spec assert spec["kind"] == "curve", "this panel is showing an image; use a different panel for curves" where = self._plot._axis_of(name) if where is not None and where != (self._index, self._right): @@ -782,105 +786,6 @@ def plot(self, *args, **kwargs) -> list: self._plot._place([name]) return [_Line(self._plot, self._index, name)] - def set_ylabel(self, ylabel, fontdict=None, labelpad=None, *, loc=None, **kwargs): - kw = _text_kw(kwargs, fontdict=fontdict, labelpad=labelpad, loc=loc) - _try("set_ylabel", str(ylabel), **kw) - self._set("ylabel", str(ylabel), kw) - - def set_ylim(self, bottom=None, top=None, *, ymin=None, ymax=None): - """Like `Axes.set_ylim`: `set_ylim(0, 1)`, `set_ylim((0, 1))`, or one end, `set_ylim(bottom=0)`.""" - self._set("ylim", _lim(bottom, top, ymin, ymax)) - - def set_yscale(self, value, **kwargs): - """Like `Axes.set_yscale`: "linear", "log", "symlog", "logit", ..., with the scale's kwargs.""" - _try("set_yscale", value, **kwargs) - self._set("yscale", value, kwargs) - - def axhline(self, y=0, xmin=0, xmax=1, **kwargs): - """ - Like `Axes.axhline`: a horizontal line at `y`, dashed grey unless kwargs say otherwise. Give it - a `label` to put it in the legend, e.g. `ax.axhline(math.log(10), label="chance", color="r")`. - """ - line = {**kwargs, "y": float(y), **({"xmin": xmin, "xmax": xmax} if (xmin, xmax) != (0, 1) else {})} - _try("axhline", **line) - self._plot._specs[self._index][self._key("axhlines")].append(line) - self._plot._send_layout() - - def set(self, **kwargs): - """Like `Axes.set`: `ax.set(ylabel="loss", ylim=(0, 1), yscale="log", title=...)`.""" - for key, value in kwargs.items(): - getattr(self, f"set_{key}")(value) - - # panel-level setters, so plot["acc"].set_title(...) works as it would on a matplotlib Axes - def set_title(self, label, fontdict=None, loc=None, pad=None, **kwargs): - self.panel.set_title(label, fontdict, loc, pad, **kwargs) - - def legend(self, **kwargs): - self.panel.legend(**kwargs) - - def set_xlabel(self, xlabel, fontdict=None, labelpad=None, *, loc=None, **kwargs): - self.panel.set_xlabel(xlabel, fontdict, labelpad, loc=loc, **kwargs) - - def set_xlim(self, left=None, right=None, *, xmin=None, xmax=None): - self.panel.set_xlim(left, right, xmin=xmin, xmax=xmax) - - def set_xscale(self, value, **kwargs): - self.panel.set_xscale(value, **kwargs) - - def axvline(self, x=0, ymin=0, ymax=1, **kwargs): - self.panel.axvline(x, ymin, ymax, **kwargs) - - def set_smooth(self, weight): - self.panel.set_smooth(weight) - - def twinx(self): - return self.panel.twinx() - - def __repr__(self): - side = "right" if self._right else "left" - return f"" - - -class Panel: - """ - One panel of the grid, the Axes of a matplotlib figure: `plot.axes[i]`, what `LivePlot.subplots` - hands out, or `plot[metric].panel`. `.left` / `.right` are its y-axes (`.right` exists only if the - panel has secondary metrics; `twinx()` makes it). y-setters here act on the left axis. - """ - - def __init__(self, plot, index: int): - self._plot, self._index = plot, index - - @property - def spec(self) -> dict: - return self._plot._specs[self._index] - - @property - def metrics(self) -> list: - return list(self.spec["metrics"]) + list(self.spec["secondary"]) - - @property - def lines(self) -> list: - """Like `Axes.lines`: a handle on each metric's line on this panel (both y-axes), for restyling.""" - return [_Line(self._plot, self._index, name) for name in self.metrics] - - @property - def left(self) -> _Axis: - return _Axis(self._plot, self._index, right=False) - - @property - def right(self) -> _Axis: - assert self.spec["secondary"], "this panel has no right-hand axis (no metrics after '|'; twinx() makes one)" - return _Axis(self._plot, self._index, right=True) - - def twinx(self) -> _Axis: - """Like `Axes.twinx`: this panel's right-hand y-axis, whether or not it holds anything yet.""" - return _Axis(self._plot, self._index, right=True) - - def plot(self, *args, **kwargs) -> list: - """Like `Axes.plot`, on the left axis: `ax.plot("loss", "r--", label="train")`. See `_Axis.plot`.""" - return self.left.plot(*args, **kwargs) - def imshow(self, x, *, rows=None, cols=None, griddim=None, vmin=None, vmax=None, scale_each=False, channels=None, cmap="gray", padding=0, pad_value=0, max_images=64, max_cols=8, **kwargs): """ @@ -920,26 +825,35 @@ def imshow(self, x, *, rows=None, cols=None, griddim=None, vmin=None, vmax=None, self._plot._send_image(self._index, arr) return self - def _set(self, key, value, kw=None): - self.spec[key] = value - if kw is not None: - self.spec["kw"][key] = kw + def axhline(self, y=0, xmin=0, xmax=1, **kwargs): + """ + Like `Axes.axhline`: a horizontal line at `y` on this y-axis, dashed grey unless kwargs say + otherwise. Give it a `label` to put it in the legend: `ax.axhline(math.log(10), label="chance")`. + """ + line = {**kwargs, "y": float(y), **({"xmin": xmin, "xmax": xmax} if (xmin, xmax) != (0, 1) else {})} + _try("axhline", **line) + self.spec[self._key("axhlines")].append(line) self._plot._send_layout() - def set_title(self, label, fontdict=None, loc=None, pad=None, **kwargs): - """Like `Axes.set_title`, text kwargs included: `set_title("training", fontsize=10, loc="left")`.""" - kw = _text_kw(kwargs, fontdict=fontdict, loc=loc, pad=pad) - _try("set_title", str(label), **kw) - self._set("title", str(label), kw) + def axvline(self, x=0, ymin=0, ymax=1, **kwargs): + """ + Like `Axes.axvline`: a vertical line at `x` across the panel, dotted grey unless kwargs say + otherwise, with a `label` for the legend. To mark the current point of a run: + `ax.axvline(plot.step, label="lr drop")`. + """ + line = {**kwargs, "x": float(x), **({"ymin": ymin, "ymax": ymax} if (ymin, ymax) != (0, 1) else {})} + _try("axvline", **line) + self.spec["axvlines"].append(line) + self._plot._send_layout() def legend(self, *args, **kwargs): """ - Like `Axes.legend`, for the panel's one legend (both y-axes' curves and its labelled reference - lines): kwargs go to matplotlib, e.g. `legend(loc="upper left", ncols=3)`, or + Like `Axes.legend`: show the panel's legend (both y-axes' curves and its labelled reference + lines), with kwargs going to matplotlib, e.g. `legend(loc="upper left", ncols=3)`, or `legend(loc="upper center", bbox_to_anchor=(0.5, -0.15), ncols=3)` to put it under the panel. - Every panel has a legend already; this only moves or styles it. What it lists comes from the - labels: `ax.plot("loss", label="train")`, `axhline(..., label=...)`, and a label starting with - "_" leaves an artist out, as in matplotlib. + A panel with two or more labelled things shows a legend without being asked. The entries come + from the labels: `ax.plot("loss", label="train")`, `axhline(..., label=...)`, and a label + starting with "_" leaves an artist out, as in matplotlib. """ assert not args, ( "legend() takes keyword arguments only: the entries come from the labels given to plot(), " @@ -948,6 +862,30 @@ def legend(self, *args, **kwargs): _try("legend", [], [], **kwargs) self._set("legend", kwargs) + # -- this y-axis ----------------------------------------------------------------- + + def set_ylabel(self, ylabel, fontdict=None, labelpad=None, *, loc=None, **kwargs): + kw = _text_kw(kwargs, fontdict=fontdict, labelpad=labelpad, loc=loc) + _try("set_ylabel", str(ylabel), **kw) + self._set(self._key("ylabel"), str(ylabel), kw) + + def set_ylim(self, bottom=None, top=None, *, ymin=None, ymax=None): + """Like `Axes.set_ylim`: `set_ylim(0, 1)`, `set_ylim((0, 1))`, or one end, `set_ylim(bottom=0)`.""" + self._set(self._key("ylim"), _lim(bottom, top, ymin, ymax)) + + def set_yscale(self, value, **kwargs): + """Like `Axes.set_yscale`: "linear", "log", "symlog", "logit", ..., with the scale's kwargs.""" + _try("set_yscale", value, **kwargs) + self._set(self._key("yscale"), value, kwargs) + + # -- the panel, shared by its two y-axes ------------------------------------------- + + def set_title(self, label, fontdict=None, loc=None, pad=None, **kwargs): + """Like `Axes.set_title`, text kwargs included: `set_title("training", fontsize=10, loc="left")`.""" + kw = _text_kw(kwargs, fontdict=fontdict, loc=loc, pad=pad) + _try("set_title", str(label), **kw) + self._set("title", str(label), kw) + def set_xlabel(self, xlabel, fontdict=None, labelpad=None, *, loc=None, **kwargs): kw = _text_kw(kwargs, fontdict=fontdict, labelpad=labelpad, loc=loc) _try("set_xlabel", str(xlabel), **kw) @@ -963,39 +901,28 @@ def set_xscale(self, value, **kwargs): self._set("xscale", value, kwargs) def set_smooth(self, weight): - """wandb-style smoothing weight in [0, 1) for every curve on this panel; 0 turns it off.""" + """wandb-style smoothing weight in [0, 1) for every curve on the panel; 0 turns it off.""" self._set("smooth", _check_smooth(weight)) - def axvline(self, x=0, ymin=0, ymax=1, **kwargs): - """ - Like `Axes.axvline`: a vertical line at `x`, dotted grey unless kwargs say otherwise, with a - `label` for the legend. To mark the current point of a run: `ax.axvline(plot.step, label="lr drop")`. - """ - line = {**kwargs, "x": float(x), **({"ymin": ymin, "ymax": ymax} if (ymin, ymax) != (0, 1) else {})} - _try("axvline", **line) - self.spec["axvlines"].append(line) - self._plot._send_layout() - def set(self, **kwargs): """Like `Axes.set`: `ax.set(title="training", xlabel="examples", ylim=(0, 1), yscale="log")`.""" for key, value in kwargs.items(): getattr(self, f"set_{key}")(value) - # y-setters act on the left axis, as on a matplotlib Axes - def set_ylabel(self, ylabel, fontdict=None, labelpad=None, *, loc=None, **kwargs): - self.left.set_ylabel(ylabel, fontdict, labelpad, loc=loc, **kwargs) - def set_ylim(self, bottom=None, top=None, *, ymin=None, ymax=None): - self.left.set_ylim(bottom, top, ymin=ymin, ymax=ymax) +_said_nothing_is_drawn = False - def set_yscale(self, value, **kwargs): - self.left.set_yscale(value, **kwargs) - def axhline(self, y=0, xmin=0, xmax=1, **kwargs): - self.left.axhline(y, xmin, xmax, **kwargs) - - def __repr__(self): - return f"Panel({self._index}: {' '.join(self.spec['metrics'])}{' | ' + ' '.join(self.spec['secondary']) if self.spec['secondary'] else ''})" +def _say_nothing_is_drawn(): + """Once per process: a LivePlot outside a notebook draws nothing, and silence would look like a bug.""" + global _said_nothing_is_drawn + if not _said_nothing_is_drawn: + _said_nothing_is_drawn = True + warnings.warn( + 'LivePlot: not in a notebook, so nothing is drawn live. Every value is kept in plot.data, and ' + 'plot.savefig("run.png") saves the plot (record="run.gif" records it as it trains).', + stacklevel=3, + ) # --------------------------------------------------------------------------- the handle @@ -1009,10 +936,11 @@ def __init__( initial: int | float = 0, unit: str = "step", unit_scale: int | float = 1, - refresh_seconds: float = 1.0, + refresh_seconds: float = 0.2, max_cols: int | None = 3, rows: int | None = None, cols: int | None = None, + figsize: tuple | None = None, cell_size: tuple = (5, 3.5), dpi: int = 100, progress: bool = True, @@ -1027,12 +955,18 @@ def __init__( the LivePlot yields its items, shows a tqdm bar (unless progress=False or it already is one) and finishes the plot when the loop ends, however it ends. For nested loops, leave it out and wrap the inner loop with `plot(iterable, **tqdm_kwargs)` instead. - panels: layout strings like "loss", "return | entropy", "lossD lossG | acc", or dicts (see - module docstring). With none, every logged metric shares one panel. + panels: layout strings like "loss", "return | entropy", "lossD lossG | acc" (see module + docstring). With none, every logged metric shares one panel. total, initial, unit, unit_scale: tqdm's arguments, with tqdm's meaning; they define the x-axis (see module docstring). """ - iterable, specs = (args[0], args[1:]) if args and not isinstance(args[0], (str, dict)) else (None, args) + iterable, specs = (args[0], args[1:]) if args and not isinstance(args[0], str) else (None, args) + for spec in specs: + if not isinstance(spec, str): + raise TypeError( + f"panels are given as strings, like \"loss | acc\", got {type(spec).__name__}: {spec!r}. " + f"Titles, limits and labels go on the axes afterwards: plot[\"acc\"].set_ylim(0, 1)" + ) self.smooth = _check_smooth(smooth) # default TWEMA weight for panels that don't set their own (wandb's smoothing slider) self._panel_defaults: dict = {} # set_all() kwargs, applied to panels created later self._specs = [self._with_defaults(_normalise_panel(p)) for p in specs] @@ -1040,7 +974,7 @@ def __init__( self._placed = {n for p in self._specs for n in p["metrics"] + p["secondary"]} # the grid and figure options the renderer needs; the dict holds subplots()' grid options and # the figure-level text (suptitle / supxlabel / supylabel) - self._layout = (max_cols, rows, cols, cell_size, dpi, unit, {}) + self._layout = (max_cols, rows, cols, cell_size, dpi, unit, {"figsize": figsize} if figsize else {}) self._iterable, self._bar, self._desc, self._progress = iterable, None, desc, progress self._own_bar = None # the bar update() opened, which finish() closes if total is None and iterable is not None: @@ -1070,6 +1004,7 @@ def __init__( self._handle = self._make_display_handle() if self._handle is None and not self._record: self.mode = "off" # not in a notebook: nothing to draw on, just collect metrics + _say_nothing_is_drawn() return try: self._start_process() @@ -1106,7 +1041,7 @@ def _add_panel(self, spec) -> int: self._specs.append(self._with_defaults(spec)) index = len(self._specs) - 1 if self._panel_defaults: - Panel(self, index).set(**self._panel_defaults) + Axes(self, index).set(**self._panel_defaults) return index @classmethod @@ -1127,7 +1062,7 @@ def subplots(cls, nrows: int = 1, ncols: int = 1, *, sharex=False, sharey=False, 2-d), so `axes[0, 1]`, `axes.flat` and `axes.flatten()` all work. `figsize` is the whole figure in inches (liveplot's own `cell_size` is per panel); `sharex` / `sharey`, `width_ratios` / `height_ratios` and `gridspec_kw` go to matplotlib's grid; `subplot_kw` is - applied to every panel with `Panel.set`, e.g. `subplot_kw=dict(xlabel="examples")`. Every + applied to every panel with `Axes.set`, e.g. `subplot_kw=dict(xlabel="examples")`. Every other keyword goes to `LivePlot` (`total=`, `unit=`, ...). """ import numpy as np @@ -1139,7 +1074,7 @@ def subplots(cls, nrows: int = 1, ncols: int = 1, *, sharex=False, sharey=False, kwargs.setdefault("rows", nrows) kwargs.setdefault("cols", ncols) if figsize is not None: - kwargs["cell_size"] = (figsize[0] / ncols, figsize[1] / nrows) + kwargs["figsize"] = figsize gridspec_kw = dict(gridspec_kw or {}) for key, value, count in (("width_ratios", width_ratios, ncols), ("height_ratios", height_ratios, nrows)): if value is not None: @@ -1156,7 +1091,7 @@ def subplots(cls, nrows: int = 1, ncols: int = 1, *, sharex=False, sharey=False, axes = np.empty((nrows, ncols), dtype=object) for r in range(nrows): for c in range(ncols): - axes[r, c] = Panel(plot, r * ncols + c) + axes[r, c] = Axes(plot, r * ncols + c) if subplot_kw: axes[r, c].set(**subplot_kw) plot._send_layout() @@ -1169,7 +1104,7 @@ def subplots(cls, nrows: int = 1, ncols: int = 1, *, sharex=False, sharey=False, @property def axes(self) -> list: """Like `Figure.axes`: the panels, in order. `plot.axes[0].set_title("training")`.""" - return [Panel(self, i) for i in range(len(self._specs))] + return [Axes(self, i) for i in range(len(self._specs))] def _axis_of(self, metric: str): """(panel index, right-hand axis?) of the axis `metric` is drawn on, or None if it isn't placed yet.""" @@ -1180,7 +1115,7 @@ def _axis_of(self, metric: str): return i, True return None - def __getitem__(self, metric: str) -> _Axis: + def __getitem__(self, metric: str) -> "Axes": """ The y-axis that holds `metric`: `plot["acc"].set_ylim(0, 1)`. Right-hand axes are found too, which is what makes the left/right distinction disappear from the API. A metric that hasn't @@ -1193,7 +1128,7 @@ def __getitem__(self, metric: str) -> _Axis: where = self._axis_of(metric) if where is None: raise KeyError(metric) - return _Axis(self, where[0], right=where[1]) + return Axes(self, where[0], right=where[1]) def _figure_text(self, method: str, text, kwargs): from matplotlib.figure import Figure @@ -1216,13 +1151,13 @@ def supylabel(self, t, **kwargs): def set_all(self, **kwargs): """ - `Panel.set(**kwargs)` on every panel, now and for any panel created later (a metric logged for + `Axes.set(**kwargs)` on every panel, now and for any panel created later (a metric logged for the first time gets its panel with these settings too): `plot.set_all(xlabel="examples", smooth=0.9, yscale="log")`. matplotlib's way is a loop over `fig.axes`, which also works here, `for ax in plot.axes: ax.set(...)`, but a loop cannot reach panels that don't exist yet. """ for key in kwargs: - assert hasattr(Panel, f"set_{key}"), f"set_all({key}=...): a panel has no set_{key}()" + assert hasattr(Axes, f"set_{key}"), f"set_all({key}=...): an Axes has no set_{key}()" self._panel_defaults.update(kwargs) for panel in self.axes: panel.set(**kwargs) @@ -1385,7 +1320,7 @@ def log(self, *args, **metrics): step = self._last_x = args[0] # explicit x for this call (and the default until iteration) if len(args) > 1: metrics = {**args[1], **metrics} - picked = {k: float(v) for k, v in metrics.items()} + picked = {k: float(v.detach() if hasattr(v, "detach") else v) for k, v in metrics.items()} # a loss that wants grad too new = [k for k in picked if k not in self.data] for name, value in picked.items(): self.data.setdefault(name, ([], [])) @@ -1393,7 +1328,7 @@ def log(self, *args, **metrics): self.data[name][1].append(value) self.latest.update(picked) if new: - self._extend_layout(new) + self._extend_layout(new, logged=True) if self.mode == "process": self._inbox.put(("data", step, picked)) self._n_logs += 1 @@ -1407,8 +1342,12 @@ def log(self, *args, **metrics): self._bar.set_postfix(self.latest, refresh=False) self._last_postfix = time.monotonic() - def _extend_layout(self, new_metrics): - """Metrics no panel mentions: all on one shared panel if no layout was given, else one panel each.""" + def _extend_layout(self, new_metrics, logged: bool = False): + """ + Metrics no panel mentions: all on one shared panel if no layout was given, else one panel each; + in a subplots() grid, onto an existing panel. `logged`: they turned up in log() rather than + being asked for by name (plot["acc"]), which in a subplots() grid is worth a warning. + """ unplaced = [m for m in new_metrics if m not in self._placed] if not unplaced: return @@ -1418,7 +1357,15 @@ def _extend_layout(self, new_metrics): # first one still empty, else the first curve panel there is. assert curves, "subplots() gave this plot no curve panel to hold " + ", ".join(unplaced) empty = [i for i in curves if not self._specs[i]["metrics"] and not self._specs[i]["secondary"]] - self._join_panel(empty[0] if empty else curves[0], unplaced) + index = empty[0] if empty else curves[0] + if logged: # every other metric was placed with ax.plot(): this one may well be a typo + names = ", ".join(map(repr, unplaced)) + warnings.warn( + f"LivePlot: logged {names}, which no ax.plot() put on an axis, so it joins {Axes(self, index)!r}. " + f"A typo? Otherwise call ax.plot() for it before the first log().", + stacklevel=3, + ) + self._join_panel(index, unplaced) elif self._explicit_layout: for m in unplaced: self._add_panel(_normalise_panel({"metrics": [m]})) @@ -1441,16 +1388,16 @@ def _join_panel(self, index: int, unplaced: list): def imshow(self, x, **kwargs): """ Like pyplot's `plt.imshow`, which draws on the current Axes: show an image on the plot's image - panel, creating it on first use, replacing whatever was there. Keywords go to `Panel.imshow`. + panel, creating it on first use, replacing whatever was there. Keywords go to `Axes.imshow`. """ for i, spec in enumerate(self._specs): if spec["kind"] == "image": - return Panel(self, i).imshow(x, **kwargs) + return Axes(self, i).imshow(x, **kwargs) assert not self._fixed_grid, \ "this plot's grid comes from subplots(); call imshow() on one of its panels instead" index = self._add_panel(_normalise_panel({"kind": "image"})) self._send_layout() - return Panel(self, index).imshow(x, **kwargs) + return Axes(self, index).imshow(x, **kwargs) def _place(self, metrics): """Record metrics as already belonging to a panel, and redraw.""" @@ -1478,11 +1425,18 @@ def _send_layout(self): self._renderer.layout = self._layout self._renderer.set_layout(self._specs) - def figure(self): + def savefig(self, fname, **kwargs): + """ + Like `Figure.savefig`: save the plot as it stands, e.g. `plot.savefig("run.png", dpi=150)`. + Works during or after training, in a notebook or not. + """ + self.snapshot().savefig(fname, **kwargs) + + def snapshot(self): """ A matplotlib Figure of the plot as it stands (same panels, data, reference lines and marks), - built on the calling thread and independent of the render process: title it, tweak it, - `fig.savefig("run.png")`, or show it in a report. Safe to call during or after training. + built on the calling thread and independent of the render process: tweak it, add to it, or + show it in a report. Safe to call during or after training. (`savefig` saves one directly.) """ renderer = _FigureRenderer(self._panels_or_placeholder(), self.x_range, self._layout, images=self._image_state()) for name, (xs, ys) in self.data.items(): diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4edd99b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,9 @@ +import pytest + +import liveplot.liveplot as lp + + +@pytest.fixture(autouse=True) +def _no_notebook_notice(monkeypatch): + """Every test runs outside a notebook; the once-per-process notice about that is tested on its own.""" + monkeypatch.setattr(lp, "_said_nothing_is_drawn", True) diff --git a/tests/test_axes_api.py b/tests/test_axes_api.py index a494818..f954e25 100644 --- a/tests/test_axes_api.py +++ b/tests/test_axes_api.py @@ -1,20 +1,25 @@ +import warnings + import numpy as np import pytest from liveplot import LivePlot -from liveplot.liveplot import Panel, _Axis, _Line, _parse_fmt +from liveplot.liveplot import Axes, _Line, _parse_fmt -def test_addressing_by_metric_and_by_panel(): +def test_one_axes_type_for_panels_twins_and_lookups(): p = LivePlot("loss | acc", "lr") p.log(0, loss=1.0, acc=0.5, lr=1e-3) - assert [type(x) for x in p.axes] == [Panel, Panel] and p.axes[0].metrics == ["loss", "acc"] + assert all(type(x) is Axes for x in p.axes) and p.axes[0].metrics == ["loss"] left, right, lr = p["loss"], p["acc"], p["lr"] - assert isinstance(left, _Axis) and not left._right and right._right and left.panel.spec is p.axes[0].spec - assert right.metrics == ["acc"] and lr.panel.spec is p.axes[1].spec - assert repr(p.axes[0]) == "Panel(0: loss | acc)" - with pytest.raises(AssertionError): - p.axes[1].right # no metrics after '|' + assert all(type(x) is Axes for x in (left, right, lr)) + assert left == p.axes[0] and right == p.axes[0].twinx() and lr == p.axes[1], "the same Axes, however it is reached" + assert right.metrics == ["acc"] and right.spec is left.spec, "twins share the panel" + assert repr(left) == "Axes(0: loss)" and repr(right) == "Axes(0 (right): acc)" + right.set_title("shared") # a panel-wide setting, from either twin + assert left.spec["title"] == "shared" + right.set_ylim(0, 1) # a y-axis setting stays on its side + assert left.spec["ylim"] is None and left.spec["ylim2"] == (0.0, 1.0) def test_matplotlib_named_setters_go_to_the_right_axis(): @@ -60,7 +65,7 @@ def test_one_sided_limit_pins_one_end_and_autoscales_the_other(): p.log(step, loss=5.0 - step / 5, acc=0.2 + step / 50) p["loss"].set_ylim(bottom=0) p["acc"].set_ylim(top=1) - fig = p.figure() + fig = p.snapshot() ax, ax2 = fig.axes[0], fig.axes[1] assert ax.get_ylim()[0] == 0 and 5.0 <= ax.get_ylim()[1] < 6, "bottom pinned, top follows the data" assert ax2.get_ylim()[1] == 1 and 0.1 < ax2.get_ylim()[0] <= 0.2, "top pinned, bottom follows the data" @@ -74,7 +79,7 @@ def test_setters_pass_matplotlib_kwargs_through_and_check_them_early(): p["acc"].set_ylabel("accuracy", color="tab:orange") p["loss"].set_yscale("symlog", linthresh=0.1) p.axes[0].set_xscale("log") - fig = p.figure() + fig = p.snapshot() ax, ax2 = fig.axes[0], fig.axes[1] assert ax.get_title(loc="left") == "training" and ax.title.get_text() == "" assert ax2.yaxis.label.get_color() == "tab:orange" @@ -127,11 +132,11 @@ def test_the_plot_is_the_figure(): p.log(0, loss=1.0, acc=0.5) p.suptitle("run 3", fontsize=14) p.supxlabel("examples") - fig = p.figure() + fig = p.snapshot() assert fig._suptitle.get_text() == "run 3" and fig._suptitle.get_fontsize() == 14 assert fig._supxlabel.get_text() == "examples" assert [ax.get_title() for ax in fig.axes if ax.get_visible()] == ["loss", "acc"], "panel titles untouched" - for name in ("set_title", "set_xlabel", "set_ylim", "axhline", "axvline", "panels"): + for name in ("set_title", "set_xlabel", "set_ylim", "axhline", "axvline", "panels", "figure"): assert not hasattr(p, name), f"{name} would act on every panel; Figure has no such method" @@ -163,9 +168,11 @@ def test_plot_takes_fmt_strings_and_line2d_kwargs(): ax.lines[0].set_color("k") # Axes.lines for step in range(3): plot.log(step, loss=1.0 / (step + 1)) - drawn = plot.figure().axes[0].get_lines()[0] + drawn = plot.snapshot().axes[0].get_lines()[0] assert (drawn.get_color(), drawn.get_linestyle(), drawn.get_linewidth(), drawn.get_alpha(), drawn.get_marker()) == ("k", "--", 3, 0.5, "o") - assert [t.get_text() for t in plot.figure().axes[0].get_legend().get_texts()] == ["train loss"] + assert plot.snapshot().axes[0].get_legend() is None, "one curve: no legend unless asked for" + ax.legend(loc="upper right") + assert [t.get_text() for t in plot.snapshot().axes[0].get_legend().get_texts()] == ["train loss"] with pytest.raises(AttributeError, match="colr"): ax.plot("loss", colr="r") with pytest.raises(AssertionError, match="log()"): @@ -185,10 +192,10 @@ def test_two_names_are_matplotlibs_x_and_y_so_they_raise(): def test_underscore_labels_stay_out_of_the_legend(): p = LivePlot("loss | acc", progress=False) p.log(0, loss=1.0, acc=0.5) - p["acc"].panel.right.plot("acc", label="_hidden") + p["acc"].plot("acc", label="_hidden") p["loss"].axhline(0.5) # unlabelled: no entry, as in matplotlib p["loss"].axhline(0.2, label="target") - fig = p.figure() + fig = p.snapshot() assert [t.get_text() for t in fig.axes[0].get_legend().get_texts()] == ["loss", "target"] @@ -200,7 +207,44 @@ def test_figure_reflects_setters(): p["acc"].set_ylabel("accuracy") p.axes[0].set_title("hello") p.axes[0].axvline(2, label="two") - fig = p.figure() + fig = p.snapshot() ax, ax2 = fig.axes[0], fig.axes[1] assert ax.get_title() == "hello" and ax2.get_ylabel() == "accuracy" and ax2.get_ylim() == (0.0, 1.0) assert "two" in [t.get_text() for t in ax.get_legend().get_texts()], "a labelled axvline is in the legend" + + +def test_log_detaches_tensors_that_want_grad(): + torch = pytest.importorskip("torch") + p = LivePlot("loss", progress=False) + w = torch.ones(1, requires_grad=True) + with warnings.catch_warnings(): + warnings.simplefilter("error") # torch warns when a tensor that wants grad is turned into a number + p.log(0, loss=(w * 2).sum()) + assert p.data["loss"][1] == [2.0] + + +def test_a_metric_no_ax_plot_declared_warns_in_a_subplots_grid(): + plot, (ax_loss, ax_lr) = LivePlot.subplots(1, 2, progress=False) + ax_loss.plot("loss") + ax_lr.plot("lr") + with pytest.warns(UserWarning, match=r"'los'.*A typo\?"): + plot.log(0, los=1.0, lr=0.1) + with warnings.catch_warnings(): + warnings.simplefilter("error") + plot.log(1, loss=1.0, los=1.0) # declared, or already warned about: quiet + plot["acc"].set_ylim(0, 1) # asked for by name: deliberate, so no warning + + +def test_savefig_and_a_notice_outside_a_notebook(tmp_path, monkeypatch): + import liveplot.liveplot as lp + + monkeypatch.setattr(lp, "_said_nothing_is_drawn", False) + with pytest.warns(UserWarning, match="not in a notebook"): + p = LivePlot("loss", progress=False) + with warnings.catch_warnings(): + warnings.simplefilter("error") + LivePlot("loss", progress=False) # once per process is enough + for step in range(5): + p.log(step, loss=1.0 / (step + 1)) + p.savefig(tmp_path / "run.png", dpi=50) + assert (tmp_path / "run.png").stat().st_size > 1000 diff --git a/tests/test_figure_export.py b/tests/test_figure_export.py index 24c339e..dc77ebd 100644 --- a/tests/test_figure_export.py +++ b/tests/test_figure_export.py @@ -2,12 +2,13 @@ def test_figure_reflects_current_state(tmp_path): - p = LivePlot("loss | acc", {"metrics": ["lr"], "smooth": 0.9}, total=100, unit="examples", unit_scale=10, progress=False) + p = LivePlot("loss | acc", "lr", total=100, unit="examples", unit_scale=10, progress=False) + p["lr"].set_smooth(0.9) for step in range(20): p.log(step * 10, loss=1.0 / (step + 1), acc=step / 20, lr=1e-3) p["loss"].axhline(0.2, label="target") p.axes[0].axvline(100, label="halfway") - fig = p.figure() + fig = p.snapshot() axes = [ax for ax in fig.axes if ax.get_visible()] assert len(axes) == 3 # panel 1 left + right axis, panel 2 ax_loss = axes[0] @@ -19,12 +20,12 @@ def test_figure_reflects_current_state(tmp_path): fig.savefig(out) assert out.stat().st_size > 1000 p.log(200, loss=0.01) # a later figure() sees the new point; the old figure is untouched - fig2 = p.figure() + fig2 = p.snapshot() assert len([ax for ax in fig2.axes if ax.get_visible()][0].get_lines()[0].get_xdata()) == 21 assert len(ax_loss.get_lines()[0].get_xdata()) == 20 def test_figure_before_any_data(): p = LivePlot(progress=False) - fig = p.figure() + fig = p.snapshot() assert fig.axes and fig.axes[0].get_title() == "waiting for data…" diff --git a/tests/test_liveplot.py b/tests/test_liveplot.py index a0f64fb..7eaec1b 100644 --- a/tests/test_liveplot.py +++ b/tests/test_liveplot.py @@ -149,7 +149,8 @@ def wait_for_first_frame(p, h, timeout=90): def test_process_mode_end_to_end(fake_notebook): - with LivePlot("loss", {"metrics": ["acc"], "ylim": (0, 1)}, total=10_000, refresh_seconds=0.2) as p: + with LivePlot("loss", "acc", total=10_000, refresh_seconds=0.2) as p: + p["acc"].set_ylim(0, 1) assert p.mode == "process" wait_for_first_frame(p, fake_notebook) n_warmup, n_frames0 = len(p.data["loss"][0]), len(fake_notebook.frames) diff --git a/tests/test_reference_lines.py b/tests/test_reference_lines.py index c4c4dd9..99572b9 100644 --- a/tests/test_reference_lines.py +++ b/tests/test_reference_lines.py @@ -4,29 +4,23 @@ import pytest from liveplot import LivePlot -from liveplot.liveplot import _FigureRenderer, _normalise_axhlines, _normalise_panel +from liveplot.liveplot import _FigureRenderer, _normalise_panel LAYOUT = (3, None, None, (4, 3), 50, "step") PNG = b"\x89PNG\r\n\x1a\n" -def test_normalise_axhlines(): - assert _normalise_axhlines(None) == [] - assert _normalise_axhlines({"uniform": 10.8, "entropy": 7}) == [{"y": 10.8, "label": "uniform"}, {"y": 7.0, "label": "entropy"}] - assert _normalise_axhlines([0.5, 1]) == [{"y": 0.5, "label": "0.5"}, {"y": 1.0, "label": "1"}] - assert _normalise_axhlines([dict(y=2, label="two", color="red", linestyle="-")]) == [{"y": 2.0, "label": "two", "color": "red", "linestyle": "-"}] - p = _normalise_panel({"metrics": ["loss"], "secondary": ["acc"], "axhlines": {"target": 0.1}, "axhlines2": [0.9]}) - assert p["axhlines"] == [{"y": 0.1, "label": "target"}] and p["axhlines2"] == [{"y": 0.9, "label": "0.9"}] +def test_panels_are_strings_and_the_setters_do_the_rest(): + with pytest.raises(TypeError, match="panels are given as strings"): + LivePlot("loss", {"metrics": ["acc"], "ylim": (0, 1)}) with pytest.raises(AssertionError): - _normalise_panel({"metrics": ["loss"], "hlines": {"a": 1}}) # the old / misspelt key is caught - with pytest.raises(AssertionError): - _normalise_axhlines([dict(label="no y")]) + _normalise_panel({"metrics": ["loss"], "hlines": {"a": 1}}) # an unknown key in an internal spec is caught def test_renderer_draws_reference_lines_with_matplotlib_kwargs(): panels = [_normalise_panel({"metrics": ["loss"], "secondary": ["acc"], "axhlines": [dict(y=2.3, label="uniform", color="red", linestyle="-")], - "axhlines2": {"solved": 0.95}, + "axhlines2": [{"y": 0.95, "label": "solved"}], "axvlines": [{"x": 5, "label": "lr drop", "color": "blue"}]}), _normalise_panel("lr")] r = _FigureRenderer(panels, xlim=None, layout=LAYOUT) @@ -72,7 +66,8 @@ def update(self, img): def test_process_mode_reference_lines(monkeypatch): h = _FakeHandle() monkeypatch.setattr(LivePlot, "_make_display_handle", staticmethod(lambda: h)) - with LivePlot({"metrics": ["loss"], "axhlines": {"uniform": math.log(50257)}}, refresh_seconds=0.1) as p: + with LivePlot("loss", refresh_seconds=0.1) as p: + p["loss"].axhline(math.log(50257), label="uniform") t0 = time.monotonic() while not h.frames and time.monotonic() - t0 < 90: # child startup p.log(0, loss=11.0) diff --git a/tests/test_robustness.py b/tests/test_robustness.py index 01ae2d0..b338f4b 100644 --- a/tests/test_robustness.py +++ b/tests/test_robustness.py @@ -15,7 +15,8 @@ def test_bad_smooth_weight_is_caught_where_it_is_given(): LivePlot("loss", smooth=bad, progress=False) with pytest.raises(AssertionError, match="weight in"): _normalise_panel({"metrics": ["loss"], "smooth": bad}) - p = LivePlot("loss", {"metrics": ["acc"], "smooth": 0}, smooth=0.9, progress=False) # the legal range still passes + p = LivePlot("loss", "acc", smooth=0.9, progress=False) # the legal range still passes + p["acc"].set_smooth(0) assert [pn["smooth"] for pn in p._specs] == [0.9, 0] diff --git a/tests/test_smoothing.py b/tests/test_smoothing.py index 68879c8..4bc9f02 100644 --- a/tests/test_smoothing.py +++ b/tests/test_smoothing.py @@ -69,13 +69,15 @@ def test_renderer_smooths_and_fades_raw(): assert r.raw_lines["loss"].get_alpha() == 0.25 and r.lines["loss"].get_alpha() is None ys, raw = r.lines["loss"].get_ydata(), r.raw_lines["loss"].get_ydata() assert len(ys) == 200 and np.std(np.diff(ys)) < np.std(np.diff(raw)) - assert [t.get_text() for t in ax.get_legend().get_texts()] == ["loss"], "the raw line has no legend entry" + assert ax.get_legend_handles_labels()[1] == ["loss"], "the raw line has no legend entry" + assert ax.get_legend() is None, "one curve: the title names it, no legend needed" assert r.lines["acc"].axes.get_yscale() == "log" assert "acc" not in r.raw_lines and len(r.lines["acc"].axes.get_lines()) == 1 def test_plot_wide_default_and_override(): - p = LivePlot("loss", {"metrics": ["acc"], "smooth": 0}, smooth=0.9) + p = LivePlot("loss", "acc", smooth=0.9) + p["acc"].set_smooth(0) p.log(0, loss=1.0, acc=0.5, lr=1e-3) # lr is discovered -> gets the default too assert [pn["smooth"] for pn in p._specs] == [0.9, 0, 0.9] q = LivePlot() diff --git a/tests/test_subplots.py b/tests/test_subplots.py index cab5b48..eb9743a 100644 --- a/tests/test_subplots.py +++ b/tests/test_subplots.py @@ -6,7 +6,7 @@ import pytest from liveplot import LivePlot -from liveplot.liveplot import Panel, _FigureRenderer, _normalise_panel +from liveplot.liveplot import Axes, _FigureRenderer, _normalise_panel LAYOUT = (3, None, None, (4, 3), 50, "step") PNG = b"\x89PNG\r\n\x1a\n" @@ -18,7 +18,7 @@ def rng_images(n=4, c=1, h=8, w=8, seed=0): def test_subplots_squeezes_like_matplotlib(): plot, ax = LivePlot.subplots(progress=False) - assert isinstance(ax, Panel), "1x1 -> the panel itself" + assert isinstance(ax, Axes), "1x1 -> the Axes itself" plot, axs = LivePlot.subplots(1, 3, progress=False) assert isinstance(axs, np.ndarray) and axs.shape == (3,), "a single row -> 1-d, so it unpacks" plot, axs = LivePlot.subplots(3, 1, progress=False) @@ -35,9 +35,14 @@ def test_subplots_squeezes_like_matplotlib(): def test_figsize_is_the_whole_figure_as_matplotlib_means_it(): - plot, _ = LivePlot.subplots(2, 4, figsize=(12, 6), progress=False) - assert plot._layout[3] == (3.0, 3.0), "cell_size is figsize / grid" + plot, axs = LivePlot.subplots(2, 4, figsize=(12, 6), progress=False) + axs[0, 0].plot("loss") + plot.log(0, loss=1.0) + assert tuple(plot.snapshot().get_size_inches()) == (12, 6) assert plot._layout[1:3] == (2, 4), "the grid is fixed at what was asked for" + grown = LivePlot("loss", figsize=(8, 3), progress=False) # the constructor takes figsize too: + grown.log(0, loss=1.0, acc=0.5, lr=0.1) # the whole figure, however many panels appear + assert tuple(grown.snapshot().get_size_inches()) == (8, 3) def test_plot_assigns_metrics_and_twinx_gives_the_right_axis(): @@ -63,13 +68,15 @@ def test_a_metric_nobody_declared_joins_a_panel_rather_than_growing_the_grid(): plot, (ax_loss, ax_img) = LivePlot.subplots(1, 2, progress=False) ax_loss.plot("lossD") ax_img.imshow(rng_images()) - plot.log(0, lossD=1.0, surprise=2.0) + with pytest.warns(UserWarning, match="A typo"): + plot.log(0, lossD=1.0, surprise=2.0) assert len(plot._specs) == 2, "the grid subplots() promised is not resized" assert plot._specs[0]["metrics"] == ["lossD", "surprise"], "it lands on the curve panel" # with an empty curve panel available, that one is preferred plot2, (a, b) = LivePlot.subplots(1, 2, progress=False) a.plot("loss") - plot2.log(0, loss=1.0, other=2.0) + with pytest.warns(UserWarning, match="A typo"): + plot2.log(0, loss=1.0, other=2.0) assert (plot2._specs[0]["metrics"], plot2._specs[1]["metrics"]) == (["loss"], ["other"]) # a grid of nothing but image panels has nowhere to put it plot3, ax = LivePlot.subplots(progress=False) @@ -132,14 +139,14 @@ def test_figure_includes_the_image(): for step in range(5): plot.log(step, loss=1.0 / (step + 1)) ax_img.imshow(rng_images(n=4, c=3), rows=2, vmin=0, vmax=1) - fig = plot.figure() + fig = plot.snapshot() assert sum(len(ax.images) for ax in fig.axes) == 1 assert sum(len(ax.get_lines()) for ax in fig.axes) == 1 img = next(im for ax in fig.axes for im in ax.images) assert img.get_array().shape == (16, 16, 3), "two rows of two 8x8 RGB images" assert img.axes.get_title() == "step 4", "an untitled image panel says when it was drawn" ax_img.set_title("samples") - assert next(im for ax in plot.figure().axes for im in ax.images).axes.get_title() == "samples (step 4)" + assert next(im for ax in plot.snapshot().axes for im in ax.images).axes.get_title() == "samples (step 4)" class _FakeHandle: @@ -209,7 +216,7 @@ def test_width_ratios_and_legend_placement(): ax_loss.legend(loc="upper center", ncols=3) ax_img.imshow(rng_images()) plot.log(0, lossD=1.0, lossG=0.5, **{"D(x)": 0.5}) - fig = plot.figure() + fig = plot.snapshot() axes = [ax for ax in fig.axes if ax.get_visible()] ax_curves, ax_image = axes[0], axes[1] assert ax_image.get_subplotspec().get_gridspec().get_width_ratios() == [1, 2] @@ -227,7 +234,7 @@ def test_sharex_gridspec_kw_and_subplot_kw_are_matplotlibs(): axs[1].plot("lr") plot.log(0, loss=1.0, lr=0.1) plot.log(10, loss=0.5, lr=0.05) - a, b = [ax for ax in plot.figure().axes if ax.get_visible()] + a, b = [ax for ax in plot.snapshot().axes if ax.get_visible()] assert a.get_shared_x_axes().joined(a, b) assert a.get_subplotspec().get_gridspec().get_height_ratios() == [2, 1] assert a.get_xlabel() == b.get_xlabel() == "examples" @@ -240,9 +247,9 @@ def test_sharex_gridspec_kw_and_subplot_kw_are_matplotlibs(): def test_imshow_passes_matplotlib_kwargs(): plot, ax = LivePlot.subplots(progress=False) ax.imshow(rng_images(), interpolation="bilinear") - assert plot.figure().axes[0].images[0].get_interpolation() == "bilinear" + assert plot.snapshot().axes[0].images[0].get_interpolation() == "bilinear" ax.imshow(rng_images()) # back to liveplot's default, crisp pixels - assert plot.figure().axes[0].images[0].get_interpolation() == "nearest" + assert plot.snapshot().axes[0].images[0].get_interpolation() == "nearest" with pytest.raises(ValueError): ax.imshow(rng_images(), interpolation="blurry") with pytest.raises(AssertionError, match="scales the pixels itself"): @@ -275,7 +282,7 @@ def test_image_panels_are_sized_to_their_picture(): ax_loss.plot("loss") plot.log(0, loss=1.0) ax_img.imshow(rng_images(n=9, c=3)) # 3x3 of 8x8: square - fig = plot.figure() + fig = plot.snapshot() assert fig.get_size_inches()[0] == pytest.approx(12), "figsize is kept" ax_curves, ax_image = [ax for ax in fig.axes if ax.get_visible()] fig.canvas.draw() @@ -283,7 +290,7 @@ def test_image_panels_are_sized_to_their_picture(): assert box.width == pytest.approx(box.height, rel=0.02), "the picture fills its panel: no side margins" assert ax_curves.get_window_extent().width > 1.5 * box.width, "the curves take the room the picture doesn't need" ax_img.imshow(rng_images(n=8, c=3), griddim=(2, 4)) # a wide picture: the panel re-fits - box = [ax for ax in plot.figure().axes if ax.get_visible()][1] + box = [ax for ax in plot.snapshot().axes if ax.get_visible()][1] box.figure.canvas.draw() extent = box.get_window_extent() assert extent.width == pytest.approx(2 * extent.height, rel=0.02)