skills/scientific-visualization/references/matplotlib_examples.md
Verified 2026-07-23 against Matplotlib 3.11.1, Seaborn 0.13.2, Plotly 6.9.0, Kaleido 1.3.0, Pillow 12.3.0, and pypdf 6.14.2. Source IDs resolve in sources.md.
Run examples from the skill directory with pinned direct dependencies:
uv run --isolated --no-project --python 3.13 \
--with "matplotlib==3.11.1" \
--with "seaborn==0.13.2" \
--with "plotly==6.9.0" \
--with "kaleido==1.3.0" \
--with "pillow==12.3.0" \
--with "pypdf==6.14.2" \
python your_figure.py
These pins are a dated direct-dependency snapshot, not a lock of all transitive artifacts. Keep a project lock when exact environment replay is required.
Matplotlib separates interactive display backends from hardcopy renderers. The current built-ins include PDF (pdf), PS (ps, eps), SVG (svg), PGF (pgf, pdf through TeX), and optional Cairo (png, ps, pdf, svg); Agg is the common raster renderer [MPL-BACKENDS]. JPEG, TIFF, and WebP saving uses Pillow through the raster path.
Available output depends on the active backend/build:
supported = fig.canvas.get_supported_filetypes()
print(supported)
Figure.savefig(..., backend="cairo") or backend="pgf" can select another renderer, but Matplotlib documents the default as normally sufficient [MPL-SAVE]. PGF requires a working TeX setup; Cairo requires pycairo or cairocffi. Inspect output because a vector container can still contain rasterized artists.
Prefer temporary style contexts to global state:
import matplotlib.pyplot as plt
from style_presets import style_context
with style_context("default", palette_name="okabe_ito_on_white"):
fig, ax = plt.subplots(
figsize=(89 / 25.4, 60 / 25.4),
layout="constrained",
)
ax.plot([0, 1, 2], [1, 3, 2], marker="o", label="Observed")
ax.set(xlabel="Time (hours)", ylabel="Response (unit)")
ax.legend()
layout="constrained" handles labels, legends, nested layouts, and colorbars more flexibly than tight_layout; calling tight_layout() turns constrained layout off [MPL-LAYOUT].
If exact page dimensions matter, do not export with bbox_inches="tight"; it recalculates the bounding box and changes the physical output size [MPL-SAVE].
You can also use the bundled parseable style:
from pathlib import Path
import matplotlib.pyplot as plt
skill_root = Path("skills/scientific-visualization")
with plt.style.context(skill_root / "assets" / "publication.mplstyle"):
fig, ax = plt.subplots(layout="constrained")
import numpy as np
import matplotlib.pyplot as plt
rng = np.random.default_rng(20260723)
groups = {
"Control": rng.normal(0.0, 1.0, 24),
"Treatment": rng.normal(0.7, 1.1, 24),
}
fig, ax = plt.subplots(figsize=(3.5, 2.8), layout="constrained")
for position, (label, values) in enumerate(groups.items()):
jitter = rng.uniform(-0.08, 0.08, len(values))
ax.scatter(
position + jitter,
values,
alpha=0.65,
label=label,
)
mean = values.mean()
sem = values.std(ddof=1) / np.sqrt(len(values))
ax.errorbar(position, mean, yerr=sem, color="black", capsize=3)
ax.set(
xticks=range(len(groups)),
xticklabels=list(groups),
ylabel="Response (unit)",
)
Caption the error bars as mean ± one SEM and state n=24 independent observations per group. If independence is false, use an analysis and interval that respects the design.
import numpy as np
time = np.arange(8)
signal = np.array([1.0, 1.4, np.nan, np.nan, 2.1, 2.0, 2.4, 2.7])
fig, ax = plt.subplots(layout="constrained")
ax.plot(time, signal, marker="o", label="Observed") # gaps remain gaps
ax.scatter([2, 3], [0.9, 0.9], marker="x", color="0.35", label="Missing")
ax.set(xlabel="Time (days)", ylabel="Signal (unit)")
ax.legend()
If a model estimates the missing interval, plot the model separately with its uncertainty and identify it as modeled, not observed.
import numpy as np
import matplotlib.pyplot as plt
concentration = np.array([0.1, 1.0, 10.0, 100.0])
response = np.array([0.4, 0.9, 2.1, 4.3])
fig, ax = plt.subplots(layout="constrained")
ax.plot(concentration, response, marker="o")
ax.set_xscale("log", base=10)
ax.set(xlabel="Concentration (µM; log10 axis)", ylabel="Response (unit)")
Do not silently omit zeros or negatives. State the measurement-domain rule or use another representation.
import matplotlib as mpl
import matplotlib.pyplot as plt
import numpy as np
values = np.array([
[-2.0, -0.5, 0.1],
[-1.1, np.nan, 1.8],
[-0.2, 0.7, 3.0],
])
norm = mpl.colors.TwoSlopeNorm(vmin=-2, vcenter=0, vmax=3)
cmap = mpl.colormaps["RdBu_r"].with_extremes(bad="#777777")
fig, ax = plt.subplots(layout="constrained")
image = ax.imshow(values, norm=norm, cmap=cmap, interpolation="nearest")
colorbar = fig.colorbar(image, ax=ax)
colorbar.set_label("Change from baseline (unit)")
ax.set(xlabel="Sample", ylabel="Feature")
TwoSlopeNorm gives each side of the center a different linear mapping. Use CenteredNorm when symmetric treatment around a center is appropriate, LogNorm for strictly positive orders of magnitude, and BoundaryNorm for declared classes [MPL-NORM].
import matplotlib.pyplot as plt
fig = plt.figure(figsize=(7.0, 4.0), layout="constrained")
subfigures = fig.subfigures(1, 2, width_ratios=[2, 1])
left_axes = subfigures[0].subplots(2, 1, sharex=True)
right_ax = subfigures[1].subplots()
for label, ax in zip("ABC", [*left_axes, right_ax]):
ax.text(
-0.12,
1.05,
label,
transform=ax.transAxes,
fontweight="bold",
va="top",
)
GridSpec, subgrids, subplot_mosaic, and subfigures all work with constrained layout [MPL-LAYOUT] [MPL-GRIDSPEC].
Dense point clouds can make PDF/SVG huge. Rasterize only the dense artist:
fig, ax = plt.subplots(layout="constrained")
ax.scatter(x, y, s=2, alpha=0.25, rasterized=True)
ax.set(xlabel="Predictor (unit)", ylabel="Outcome (unit)")
from figure_export import export_figure
report = export_figure(
fig,
"outputs/figure1",
formats=["pdf", "png"],
dpi=600, # controls PNG and rasterized artists embedded in PDF
provenance={
"raw_data": "data/observations.csv",
"transformations": ["rows filtered by predeclared QC flag"],
"uncertainty": "none displayed",
"missing_data": "retained as gaps",
},
write_manifest=True,
)
The exporter:
It does not inspect data truth or certify submission compliance.
report = export_figure(
fig,
"outputs/microscopy_panel",
formats=["tiff"],
dpi=300,
facecolor="white",
overwrite=False,
)
Then inspect:
uv run --isolated --no-project --python 3.13 \
--with "pillow==12.3.0" \
python scripts/image_metadata.py outputs/microscopy_panel.tiff \
--format tiff --mode RGB --min-dpi 300 --target-width-mm 85 \
--alpha-policy forbid
Effective DPI is pixel width divided by final width in inches. Changing only the TIFF DPI tag does not create detail.
Seaborn remains built on Matplotlib. Use axes-level functions for custom multi-panel layouts and figure-level functions for automatic faceting [SEABORN-FAQ].
import seaborn as sns
import matplotlib.pyplot as plt
from color_palettes import OKABE_ITO_ON_WHITE
from style_presets import style_context
sns.set_theme(style="ticks", context="paper", palette=OKABE_ITO_ON_WHITE)
with style_context("default", palette_name="okabe_ito_on_white"):
fig, ax = plt.subplots(figsize=(3.5, 2.8), layout="constrained")
sns.lineplot(
data=frame,
x="time",
y="response",
hue="treatment",
style="treatment",
markers=True,
errorbar=("ci", 95),
n_boot=5000,
seed=20260723,
ax=ax,
)
ax.set(xlabel="Time (hours)", ylabel="Response (unit)")
Current errorbar choices include "sd", "se", "pi", "ci", tuples, callables, or None. The old ci= interface is not the current general API [SEABORN-ERROR].
For categorical axes whose numeric/datetime values must retain their real spacing, use supported functions with native_scale=True. Do not assume every categorical plot uses native coordinates by default.
Interactive HTML:
fig.write_html(
"outputs/exploration.html",
include_plotlyjs=True, # self-contained, larger file
full_html=True,
)
Static image:
fig.write_image(
"outputs/figure.svg",
width=700,
height=450,
scale=1,
)
Batch export is faster with Kaleido v1:
import plotly.io as pio
pio.write_images(
fig=[figure_a, figure_b],
file=["outputs/a.pdf", "outputs/b.pdf"],
)
Current facts [PLOTLY-STATIC] [KALEIDO]:
write_image supports PNG, JPEG, WebP, SVG, and PDF.engine= and Orca are deprecated; do not use them in new code.plotly.io.kaleido.scope is deprecated; use plotly.io.defaults.scale multiplies output pixels; scale=3 is not inherently “300 DPI.”An interactive HTML file does not replace a static fallback, caption, alt text, keyboard review, or accessible data table.
Matplotlib 3.11.1 defaults PDF/PS to Type 3 and SVG text to paths. The bundled presets instead use PDF/PS Type 42 and leave SVG text as text [MPL-STYLE]. Verify the actual PDF:
uv run --isolated --no-project --python 3.13 \
--with "pypdf==6.14.2" \
python scripts/image_metadata.py outputs/figure1.pdf
SVG text is not an embedded font; its appearance depends on the renderer’s installed fonts. If portability matters more than editable/searchable text, use paths and retain an editable source separately.
Use opaque white submission output unless transparency is explicitly required. Transparent artists blend with the destination and can change apparent contrast [MPL-SAVE].