Faceting
In one line: small multiples — one panel per subset, same scales — which is usually a better answer than cramming everything into one panel.
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 ofscatterplot. figsize=instead ofheight/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. Useg.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