Plotting04/14
Starting sandbox…
Beginner·4 graded tests

Faceting

Faceting

Core Principle: Faceting (small multiples) partitions multidimensional datasets into synchronized subplots sharing identical scale limits across experimental conditions or categorical subsets.

Why facet

Tufte's "small multiples": repeating the same plot across subsets lets the eye compare shapes directly, because everything except the subsetting variable is held constant. Six overlapping lines in one panel become six clear panels.

The rule: when a hue= gets crowded, facet instead. Beyond about five categories, overlapping series stop being readable.

The syntax

g = sns.relplot(data=long, x="dose", y="response",
                col="gene",            # one column per gene
                row="tissue",          # one row per tissue
                hue="condition",       # colour within each panel
                kind="line",
                height=2.5, aspect=1.2,
                col_wrap=4,            # wrap into a grid (col only, no row)
                col_order=GENE_ORDER,
                facet_kws=dict(sharey=False))

Only figure-level functions facet: relplot, displot, catplot, lmplot, plus pairplot and jointplot which facet implicitly. See Figure-level vs Axes-level.

Sharing axes: the crucial decision

sns.relplot(..., facet_kws=dict(sharex=True, sharey=True))    # default
sns.relplot(..., facet_kws=dict(sharey=False))                 # per-panel y

Shared axes are the default and usually correct, because the whole point of small multiples is comparability. A reader assumes panels share a scale.

Unshared axes are legitimate when panels have genuinely different magnitudes (one gene expressed at 10 CPM, another at 10,000). But then say so clearly in the caption, because otherwise a panel where the values vary by 2% will look identical to one where they vary by 200×.

Customising the grid

g.set_axis_labels("Dose (µM)", "Expression (log$_2$ CPM)")
g.set_titles("{col_name}")                       # just the value
g.set_titles("{col_var} = {col_name}")           # "gene = TP53"
g.set_titles(row_template="{row_name}", col_template="{col_name}")
g.set(xscale="log", ylim=(0, None))
g.tight_layout()
g.add_legend(title="Condition")
g.despine(left=True)
g.savefig("facets.pdf", dpi=300)

for ax in g.axes.flat:
    ax.axhline(0, ls="--", c="grey", lw=0.8)

for gene, ax in g.axes_dict.items():             # per-facet customisation
    ax.set_title(PRETTY_NAMES.get(gene, gene), fontsize=9)

Adding layers to every facet

g.map_dataframe(sns.rugplot, x="dose", height=0.05, color="grey")
g.map(plt.axhline, y=0, ls="--", c="grey", lw=0.8)
g.refline(y=0, x=1)                       # convenience for reference lines

g.refline() is the cleanest way to put the same threshold line in every panel.

FacetGrid directly

For a plot type seaborn does not provide:

g = sns.FacetGrid(long, col="gene", col_wrap=4, height=2.5, sharey=False)
g.map_dataframe(my_custom_plot, x="dose", y="response")
g.set_titles("{col_name}")

map_dataframe passes the facet's subset as data=; map passes columns as positional arrays. Prefer map_dataframe: it works with seaborn functions and keeps the column names.

Ordering facets

sns.relplot(..., col="gene", col_order=["TP53", "BRCA1", "EGFR"])
sns.catplot(..., x="condition", order=["control", "low", "high"])

# or set it once via an ordered categorical: affects every plot
long["condition"] = pd.Categorical(long["condition"],
                                   categories=["control", "low", "high"],
                                   ordered=True)

The categorical approach is better: it fixes ordering in sort_values, groupby and every plot simultaneously, so you never have to remember order= again. See Categorical dtype.

pairplot

Faceting a dataset against itself:

sns.pairplot(df[["PC1", "PC2", "PC3", "PC4"]], hue="batch",
             diag_kind="kde", corner=True, plot_kws=dict(s=15, alpha=0.6))

corner=True drops the redundant upper triangle. For anything past ~6 variables it becomes unreadable: use a correlation heatmap instead.

For QC, pairplot across the first few PCs coloured by batch is the fastest way to see whether a technical variable dominates your variation.

Manual faceting

When you need axes-level control or a mixed layout:

genes = long["gene"].unique()
n = len(genes)
ncol = 4
nrow = -(-n // ncol)                       # ceiling division

fig, axes = plt.subplots(nrow, ncol, figsize=(3*ncol, 2.5*nrow),
                         sharex=True, sharey=True, layout="constrained")

for ax, gene in zip(axes.flat, genes):
    sub = long[long["gene"] == gene]
    sns.lineplot(data=sub, x="dose", y="response", hue="tissue",
                 ax=ax, legend=(gene == genes[0]))
    ax.set_title(gene, fontsize=9)

for ax in axes.flat[n:]:                   # hide unused panels
    ax.set_visible(False)

Two details worth stealing: legend=(gene == genes[0]) draws one legend instead of sixteen, and the final loop hides the empty panels left over when your count is not a multiple of ncol.

When faceting stops working

Too many facets. Past ~25 panels nobody reads them. Options: pick the top N by some criterion, aggregate the rest into "other", or switch to a heatmap where each row is what would have been a panel.

Too few points per facet. Twenty panels of three points each is noise presented as structure.

The comparison is between facets. Faceting makes within-panel comparison easy and between-panel comparison hard. If the reader needs to compare panel A to panel B precisely, overlay them instead.

Bioinformatics examples

# expression across conditions, one panel per gene of interest
g = sns.catplot(data=long[long["gene"].isin(PANEL)],
                x="condition", y="log_cpm", col="gene", col_wrap=4,
                kind="box", height=2.5, sharey=False)
g.map_dataframe(sns.stripplot, x="condition", y="log_cpm",
                color="black", size=2, alpha=0.6)

# QC metric distributions, one panel per metric
g = sns.displot(data=qc.melt(id_vars="sample"), x="value",
                col="variable", col_wrap=3, height=2.5,
                facet_kws=dict(sharex=False, sharey=False))

# per-chromosome variant density
g = sns.displot(data=variants, x="pos", col="chrom", col_wrap=6,
                col_order=CHROM_ORDER, height=1.8,
                facet_kws=dict(sharex=False))

# dose-response across cell lines
sns.relplot(data=dose, x="conc", y="viability", col="cell_line",
            hue="drug", kind="line", col_wrap=5, height=2)

# PCs against each other, coloured by batch: the QC workhorse
sns.pairplot(pcs, vars=["PC1", "PC2", "PC3"], hue="batch", corner=True)

Note sharex=False in the QC-metrics example: those panels show different metrics on different scales, so sharing the x-axis would be meaningless.

Common mistakes

  • Faceting on an axes-level function. col= is not a parameter of scatterplot.
  • figsize= instead of height/aspect.
  • Unshared scales without saying so.
  • Too many facets.
  • Alphabetical facet order when a meaningful order exists.
  • A legend repeated in every panel (manual faceting).
  • plt.title() after a figure-level call: hits one facet. Use g.figure.suptitle().
  • Empty trailing panels in a manual grid.

See also

Figure-level vs Axes-level · Seaborn · Subplots and Layout · Categorical dtype · Common Plot Types · Applied - Quality Control Plots

Exercise

seaborn is not available in this sandbox, so build the facet grid the way the lesson's "manual faceting" section does: with plt.subplots: and confirm from the resulting Axes that it behaves like a FacetGrid. Write manual_facets(rows), where rows is a list of (gene, dose, response) triples. Take the genes in order of first appearance, fix the grid at three columns, and use ceiling division for the number of rows. Create the grid with sharex=True, sharey=True and squeeze=False, plot each gene's points sorted by dose into its own panel, title that panel with the gene name, and hide every leftover panel with set_visible(False).

Return three lines: grid=<nrow>x<ncol> panels=<total axes> visible=<used axes>; titles= followed by the titles of the used panels in grid order, comma separated; and ylim=<lo> <hi> with both limits rounded to three decimals. Collect the y limits of every used panel and report ylim=unshared if they are not all identical: with sharey=True they will be, which is the property that makes small multiples comparable. A single gene still produces a full row of three panels with two of them hidden.

exerciseEvery submission runs independently in a fresh namespace.

Test cases · 4

#viainputexpected stdout
1stdinTP53 0 1.0 TP53 1 2.5 BRCA1 0 0.5 BRCA1 1 0.9 EGFR 0 3.0 EGFR 1 1.2 KRAS 0 2.0 KRAS 1 2.8 grid=2x3 panels=6 visible=4 titles=TP53,BRCA1,EGFR,KRAS ylim=0.375 3.125
2stdinMYC 0 1.0 MYC 1 4.0 MYC 2 9.0 grid=1x3 panels=3 visible=1 titles=MYC ylim=0.6 9.4
3stdinA 0 1.0 B 0 2.0 C 0 3.0 A 1 1.5 B 1 2.5 C 1 3.5 grid=1x3 panels=3 visible=3 titles=A,B,C ylim=0.875 3.625
4stdinTP53 1 0.1 BRCA1 1 0.4 grid=1x3 panels=3 visible=2 titles=TP53,BRCA1 ylim=0.085 0.415

Hints · 3

01Hint

Ceiling division without importing math: `-(-n // ncol)`.

02Hint

`squeeze=False` keeps `axes` two-dimensional even for one row, so `axes.flat` always works.

03Hint

Autoscaling is deferred; call `fig.canvas.draw()` before reading `ax.get_ylim()`.