Plotting07/14
Starting sandbox…
Beginner·4 graded tests

Matplotlib Artists

Matplotlib Artists

Core Principle: Every visible visual element in Matplotlib inherits from the Artist base class, exposing direct programmatic inspection and modification through getter and setter interfaces.

The idea

Every visible element is an Artist. Plotting functions return the Artists they created, so you can keep them and modify them later.

lines = ax.plot(x, y)                 # returns a LIST of Line2D
line = lines[0]
line.set_color("crimson")
line.set_linewidth(2)
line.set_linestyle("--")
line.set_alpha(0.7)
line.set_label("treated")
line.set_zorder(10)                   # draw on top

ax.plot returning a list trips people up: it can plot multiple lines at once. The idiom is line, = ax.plot(...) (note the comma) to unpack a single one.

The Artist families

Class What it is Created by
Line2D a line, or markers ax.plot
PathCollection many markers as one object ax.scatter
Rectangle / Patch bars, spans, shapes ax.bar, ax.axvspan
Text any text ax.set_title, ax.annotate
AxesImage a raster image ax.imshow
QuadMesh a mesh ax.pcolormesh, sns.heatmap
PolyCollection filled regions ax.fill_between
Legend the legend box ax.legend()
Spine the panel borders automatic

Note scatter returns one PathCollection for all points, not one Artist per point. That is why scatter is fast, and why you set properties on the collection as a whole (or with per-point arrays).

Get and set

Every property has a matched pair:

line.get_color(); line.set_color("red")
ax.get_xlim();    ax.set_xlim(0, 10)
text.get_fontsize(); text.set_fontsize(8)

line.set(color="red", lw=2, alpha=0.5)     # several at once
plt.getp(line)                              # list every property
plt.setp(lines, color="grey", alpha=0.3)    # set on many Artists at once

plt.getp(artist) is the discovery tool: it prints everything you can change on that object.

Finding Artists after the fact

ax.lines            # all Line2D
ax.collections      # scatter, fill_between
ax.patches          # bars, shapes
ax.texts            # annotations
ax.images
ax.get_children()   # everything
ax.get_legend()
ax.get_xticklabels()

This is how you modify a plot someone else drew: including one seaborn drew:

ax = sns.boxplot(data=df, x="condition", y="value")
for patch in ax.patches:
    patch.set_alpha(0.6)
    patch.set_edgecolor("black")

Any seaborn plot is a bag of Artists you can post-process. That is the escape hatch for "seaborn almost does what I want".

zorder: draw order

ax.scatter(x, y, zorder=3)
ax.axhline(0, zorder=1)          # behind the points
ax.grid(True, zorder=0)

Higher zorder draws on top. Defaults: images 0, patches 1, lines 2, text 3. The usual need is pushing gridlines behind data: which requires ax.set_axisbelow(True) for gridlines specifically, because they are a special case.

Text and annotation

ax.text(x, y, "TP53", fontsize=8, ha="center", va="bottom")

ax.annotate(
    "TP53",
    xy=(x, y),                              # the point
    xytext=(x + 0.5, y + 1),                # where the label goes
    arrowprops=dict(arrowstyle="->", lw=0.8, color="grey"),
    fontsize=8,
)

# coordinates relative to the Axes rather than the data
ax.text(0.02, 0.98, "A", transform=ax.transAxes,
        fontsize=12, fontweight="bold", va="top")

transform=ax.transAxes uses (0,0)=bottom-left, (1,1)=top-right of the panel, independent of the data range. This is how you place panel labels (A, B, C) consistently across a multi-panel figure.

Matplotlib renders LaTeX-style maths in any text:

ax.set_xlabel(r"log$_2$ fold change")
ax.set_ylabel(r"$-$log$_{10}$ $p_{\mathrm{adj}}$")
ax.set_title(r"$\Delta$ expression")

Use a raw string (r"...") so backslashes survive.

Labelling selected points

The archetypal bioinformatics annotation task:

top = df.nlargest(10, "neglog10p")
for _, row in top.iterrows():
    ax.annotate(row["gene"], (row["log2fc"], row["neglog10p"]),
                fontsize=7, xytext=(3, 3), textcoords="offset points")

Labels will overlap. The adjustText package repels them automatically and is worth installing:

from adjustText import adjust_text
texts = [ax.text(r.log2fc, r.neglog10p, r.gene, fontsize=7)
         for r in top.itertuples()]
adjust_text(texts, ax=ax, arrowprops=dict(arrowstyle="-", lw=0.5, color="grey"))

Legend control

ax.legend(frameon=False, loc="upper right", fontsize=8, ncol=2)
ax.legend(bbox_to_anchor=(1.02, 1), loc="upper left")     # outside the panel
ax.legend(title="Condition")

handles, labels = ax.get_legend_handles_labels()
ax.legend(handles[:3], labels[:3])                         # subset

from matplotlib.lines import Line2D
custom = [Line2D([0], [0], marker="o", color="w",
                 markerfacecolor="crimson", label="significant")]
ax.legend(handles=custom)

Building custom handles is how you produce a legend for something matplotlib did not label automatically: e.g. a scatter coloured by a continuous array.

Only artists with a label that does not start with _ appear. ax.plot(x, y, label="_nolegend_") hides one deliberately.

Rasterizing for large scatters

ax.scatter(x, y, s=1, rasterized=True)
fig.savefig("out.pdf", dpi=300)

A vector PDF with 500,000 scatter points is enormous and crashes viewers. rasterized=True renders just that Artist as pixels while keeping text and axes as vectors: small file, editable labels, no crash. This is essential for volcano plots, Manhattan plots and single-cell embeddings.

Common mistakes

  • Forgetting ax.plot returns a list. Use line, = ax.plot(...).
  • Modifying an Artist after savefig and expecting the file to change.
  • Losing the return value and then trying to find the Artist by digging through ax.get_children().
  • Text in data coordinates when you meant Axes coordinates.
  • Overlapping labels. Use adjustText or label fewer points.
  • Non-rasterized huge scatters in a vector output.
  • zorder with gridlines: needs ax.set_axisbelow(True).
  • Non-raw strings with LaTeX, so \D becomes an escape sequence.

See also

Figure and Axes · Axes Styling and Annotation · Saving Figures · Colormaps and Color · Applied - Differential Expression Volcano Plot

Exercise

Write artist_report(series), where series is a list of (label, values) pairs, one per input line. Plot each series against its index, unpacking the single Line2D that ax.plot returns inside a list, and set its line width to 1.5 through the Artist rather than through the plotting call. Find the series with the highest final value, raise its zorder to 10, recolour it, and mark its last point with ax.scatter. Build a legend only when ax.get_legend_handles_labels() actually returns handles; a label beginning with an underscore is deliberately hidden, and calling ax.legend() with nothing to show produces an empty box.

Return three lines: lines=<n> collections=<n> legend=<n>, counting the Axes' Line2D artists, its collections and the entries in the legend, which is zero when ax.get_legend() is None; labels= followed by the legend-visible labels in draw order and comma separated, or none; and top=<label>, the promoted series, or none when there is no input. A file in which every label starts with an underscore must still report its lines and collections.

exerciseEvery submission runs independently in a fresh namespace.

Test cases · 4

#viainputexpected stdout
1stdincontrol 1.0 2.0 3.0 treated 1.0 4.0 2.5 drug 0.5 0.5 6.0 lines=3 collections=1 legend=3 labels=control,treated,drug top=drug
2stdin_hidden 1.0 2.0 _also 3.0 1.0 lines=2 collections=1 legend=0 labels=none top=_hidden
3stdinTP53 1.0 9.0 _background 0.1 0.2 BRCA1 2.0 3.0 lines=3 collections=1 legend=2 labels=TP53,BRCA1 top=TP53
4stdinsolo 4.0 4.0 4.0 lines=1 collections=1 legend=1 labels=solo top=solo

Hints · 3

01Hint

`line, = ax.plot(...)` unpacks the one-element list `plot` returns.

02Hint

`ax.scatter` adds a single PathCollection for all its points, not one Artist per point.

03Hint

`ax.get_legend()` is None until a legend has actually been created.