Skip to content

Figures and tables

Examples write to examples/outputs/<name>/: *.png figures, *.gif animations, *.json metrics at full precision, and *.tex tables of the same numbers formatted for a paper.

pip install "xwm[plots]"
xwm.plots.save_table(out / "results", headers, rows, caption=..., label=...)
xwm.plots.save_metrics(out / "metrics", {"probe_r2": 0.295})
xwm.plots.save_gif(out / "rollout.gif", [real, imagined], labels=["real", "imagined"])
xwm.plots.save_figure(ax.figure, out / "curve.png")

Rounding is a display concern, so the .tex rounds and the .json does not. Every example writes both, from the same numbers: the table is for reading, the JSON is for comparing runs.

Palettes, and their measured limits

Two named palettes, chosen by what the chart is doing:

palette for colours
"blue-orange" (CURVE_PALETTE) curves: losses, histories, error-vs-horizon Wong's colourblind-safe blue, orange, sky blue, vermillion
"viridis" (MAGNITUDE_PALETTE) magnitude: images, PCA colourbars, many-way bars perceptually uniform

Line charts usually carry two to four series and need maximum separation between them; the Wong family delivers adjacent-pair ΔE of 24–36 in OKLab, passing every check in both light and dark mode. A continuous quantity wants a perceptually uniform ramp instead.

Using viridis for categorical series means sampling discrete steps from a sequential ramp. That works, but only up to a point, and the point is measurable rather than a matter of taste:

slots CVD separation ΔE normal-vision ΔE
2 62.8 63.7
3 28.3 33.0
4 18.6 22.4
5 13.6 16.3
6 10.4 13.2, too low

A normal-vision ΔE below 15 means readers with full colour vision cannot reliably tell the pair apart, and no amount of secondary encoding fixes that. So viridis_colors refuses more than MAX_CATEGORICAL = 5 series and tells you to use small multiples instead. It is a limit that is much more useful enforced than documented.

xwm.plots.viridis_colors(6)
# ValueError: 6 categorical series exceeds the 5 that viridis can separate ...

Two further consequences of using a sequential ramp categorically, both handled:

  • Its ends are very dark and very light, so the extreme steps have low contrast against the plot surface. Every figure therefore carries a legend, and every example also writes the same numbers as a table, so nothing depends on reading a colour.
  • Its middle is desaturated and reads grey-ish. Series get distinct markers and line styles as well as colours, so identity never rests on hue alone.

Style

with xwm.plots.plot_style(n_series=4, palette="blue-orange"):
    fig, ax = plt.subplots()
    ...

xwm.plots.save_figure(fig, out / "curve.png")

plot_style is a context manager over matplotlib's rcParams; series_style(i, n) returns the colour, marker and linestyle for series i if you are drawing by hand.

Ready-made plots

function draws
plot_history training curves from Trainer.fit's history
plot_horizon error against rollout horizon, with a baseline
plot_spectrum / plot_spectra singular values of an embedding matrix
plot_latent_pca latents in 2-D, coloured by a ground-truth quantity
plot_mask a context/target split over the token grid
plot_rollout real against imagined frames
plot_frames a strip of frames
plot_bars a categorical comparison
fig, ax = plt.subplots()
xwm.plots.plot_history(history, keys=["loss", "embed_std"], ax=ax, logy=True)
Latent PCA coloured by true position
plot_latent_pca: embeddings in two dimensions, coloured by the ground-truth quantity a probe would try to recover.

Tables

paths = xwm.plots.save_table(
    out / "results",
    ["policy", "mean (m)", "% of gap closed"],
    rows,
    caption="Franka tool distance to a goal image after 5 steps.",
    label="tab:franka-planning",
    float_format="{:.4f}",
)
# {'json': .../results.json, 'tex': .../results.tex}

One call writes both formats from one set of rows, so the LaTeX in a paper and the JSON a script compares can never disagree: the .tex rounds to float_format, the .json keeps full precision. table_to_dict reads one back; latex_table and markdown_table render to a string without writing, which is how the tables in these docs were produced.

Animations

xwm.plots.save_gif(
    out / "rollout.gif", [real, imagined],
    labels=["real", "imagined"], fps=8, scale=3,
)

Frames are (T, 3, H, W) floats or uint8; a list of stacks is tiled side by side with labels. frames_to_uint8, tile_frames and upscale are the pieces, if you want to assemble something else.

Matplotlib is optional

xwm.plots imports matplotlib lazily, so the core library installs and trains without it. The functions that need it raise a clear error rather than failing at import time, which is why plots is an extra and not a dependency.