Multi-panel figures combine 2+ subplots into a single Figure with a shared caption. This file is your reference for choosing the right composition strategy and the right helper for each archetype.
All helpers documented here are defined in scripts/ssci_style.py. This
file documents selection and usage patterns -- it does not
re-define HEX, DPI, or size literals. Always call helpers at runtime
rather than reproducing values in your own scripts.
Seven helpers cover the full multi-panel space. Pick the one that matches your problem; they compose with one another.
Question 1: "Are all panels the same chart type / spec, just different
data slices or groups?"
|-- YES --> small_multiples(n_rows, n_cols)
| (the right answer for the "9 plots stacked together"
| question; auto-shares axes by default)
|
Question 2: "Are panels different chart types arranged in a regular
rectangular layout?"
|-- YES --> compose_grid("ABC;DEF")
| (mosaic string layout, single Figure, single suptitle)
|
Question 3: "Do I need per-region suptitle / colorbar / legend scope?"
|-- YES --> compose_subfigures(2, 1)
| (each region is an independent SubFigure; spine alignment
| across regions is NOT guaranteed)
|
Question 4: "Is one chart enhanced by marginal distributions?"
|-- YES --> ax.scatter(...) then add_marginal_hist(ax, x, y)
| (niche; A0-9 confirms rare in top SSCI -- mostly used
| in cognitive psych RT x accuracy)
|
Question 5: "Do I need to zoom into a region of a single panel?"
|-- YES --> add_inset(ax, bbox, xlim, ylim, zoom_indicator=True)
| (NEJM Kaplan-Meier early zoom, RD plot McCrary density,
| choropleth AK/HI inset)
|
Question 6: "Should multiple panels share one colorbar?"
|-- YES --> add_shared_colorbar(fig, im, axes=axes.ravel().tolist())
| (one colorbar serves all panels; "steal space" is the
| default idiom)
|
Question 7: "Should multiple panels share one legend?"
|-- YES --> add_shared_legend(fig, axes[0], loc='lower center',
bbox_to_anchor=(0.5, -0.05), ncol=2)
(deduplicates handles by default; constrained_layout
caveats discussed in §7)
These are not mutually exclusive. A realistic 6-panel dashboard might chain three of them:
fig, axd = compose_grid("AAB;CDB", figsize='full_width')
add_inset(axd['A'], bbox=(0.55, 0.20, 0.40, 0.35),
xlim=(0, 1), ylim=(0.7, 1.0), zoom_indicator=True)
add_shared_legend(fig, axd['A'], loc='lower center',
bbox_to_anchor=(0.5, -0.03), ncol=3)The simplest multi-panel cases. Use compose_grid with a one-row or
one-column mosaic string.
The textbook layout: three independent panels at full-figure width, each with its own axes and labels. Use this when you have three related results (e.g., three DVs, three time windows, three subgroups) that are small enough to read side-by-side at print width.
from ssci_style import apply_style, compose_grid
apply_style(journal='apa')
fig, axd = compose_grid("ABC", figsize='full_width')
# Panel A: scatter + regression fit
axd['A'].scatter(cortisol, rt_ms, s=8, alpha=0.5)
axd['A'].plot(x_fit, y_fit, lw=1.5)
axd['A'].set_xlabel('Cortisol ($\\mathit{\\mu}$g/dL)')
axd['A'].set_ylabel('Response time (ms)')
# Panel B: grouped box
axd['B'].boxplot([low, mid, high], labels=['Low', 'Mid', 'High'])
axd['B'].set_xlabel('Stress condition')
axd['B'].set_ylabel('Cognitive load (0--100)')
# Panel C: line + CI ribbon
axd['C'].plot(weeks, mean_y, lw=1.5)
axd['C'].fill_between(weeks, lo, hi, alpha=0.18)
axd['C'].set_xlabel('Week')
axd['C'].set_ylabel('Symptom severity')
# panel_labels='auto' is the default; it calls add_panel_labels
# internally using the active preset's case + size.Rendering: three equal-width panels share the full-width figure size.
constrained_layout auto-adjusts spacing. A/B/C labels are applied
according to the active preset's panel_label_case / panel_label_size
(see scripts/ssci_style.py for per-preset values).
Same data, vertical arrangement. Use this when each panel is wider than it is tall -- e.g., three time-series stacked.
fig, axd = compose_grid("A;B;C", figsize=('full_width'))
# Equivalent (newline-separated mosaic):
fig, axd = compose_grid("A\nB\nC", figsize='full_width')
axd['A'].plot(time, treatment_arm, lw=1.2)
axd['A'].set_ylabel('Score')
axd['A'].set_title('Treatment arm', loc='left')
axd['B'].plot(time, control_arm, lw=1.0, color=ref)
axd['B'].set_ylabel('Score')
axd['B'].set_title('Control arm', loc='left')
axd['C'].plot(time, difference, lw=1.5)
axd['C'].fill_between(time, ci_lo, ci_hi, alpha=0.18)
axd['C'].set_xlabel('Time (weeks)')
axd['C'].set_ylabel('Difference')When the three panels share the x-axis, pass share='col' so only the
bottom panel shows tick labels.
share accepts True, 'all', 'row', 'col', or False. The
shortcut for "share everything" is share='all'.
fig, axd = compose_grid("ABC", figsize='full_width', share='all')
# All three panels share x and y. Inner tick labels hide automatically.When you have a 2x2 factorial of conditions, panel labels A--D in
reading order, with share='row' so the two top panels share x and the
two bottom panels share x (and same for y by row).
fig, axd = compose_grid("""
AB
CD
""", figsize='full_width', share='row')
axd['A'].plot(time, baseline_treatment, lw=1.5)
axd['A'].set_title('Treatment, pre-intervention', fontsize=9)
axd['A'].set_ylabel('Outcome')
axd['B'].plot(time, baseline_control, lw=1.5, color=ref)
axd['B'].set_title('Control, pre-intervention', fontsize=9)
axd['C'].plot(time, follow_treatment, lw=1.5)
axd['C'].set_title('Treatment, post-intervention', fontsize=9)
axd['C'].set_xlabel('Months')
axd['C'].set_ylabel('Outcome')
axd['D'].plot(time, follow_control, lw=1.5, color=ref)
axd['D'].set_title('Control, post-intervention', fontsize=9)
axd['D'].set_xlabel('Months')share='row' is compatible with subplot_mosaic(sharex='row')
semantics. If all four panels show the same DV at the same scale, use
share='all' so the y-axis ticks only show on the leftmost column.
A canonical 2x2 application: pre/post panels for treatment and control, each with raw points + means + 95% CI.
focal, ref = get_emphasis_pair('blue')
fig, axd = compose_grid("AB;CD", figsize='full_width', share='all')
for letter, condition, arm in zip('ABCD', conditions, arms):
raw = data[condition][arm]
ax = axd[letter]
ax.scatter(np.full_like(raw, 0.0) + np.random.uniform(-0.1, 0.1, len(raw)),
raw, s=5, alpha=0.25,
color=focal if arm == 'treatment' else ref)
ax.errorbar([0.0], [raw.mean()], yerr=[raw.std()/np.sqrt(len(raw)) * 1.96],
fmt='o', color=focal if arm == 'treatment' else ref,
capsize=3, lw=1.0)
ax.set_title(f'{condition} -- {arm}', fontsize=9)
# Inline summary stats per panel:
for letter in 'ABCD':
add_inline_stats(axd[letter],
[('M', means[letter]),
('SD', sds[letter]),
('n', ns[letter])],
position='upper_right')When all panels show the same chart type with different data slices,
small_multiples is the one-line answer. Defaults: shared axes, auto
panel labels, full-width figsize.
This is the most common user request. Each panel shows the same chart type (UMAP scatter, time-series, distribution) for a different group (gene, region, cohort).
fig, axes = small_multiples(3, 3, figsize='full_width',
share='all',
panel_labels='lower')
for i, gene in enumerate(NINE_GENES):
ax = axes.ravel()[i]
sc = ax.scatter(umap_x, umap_y,
c=expression_per_gene[gene],
cmap=get_sequential_cmap('viridis'),
s=4, alpha=0.7)
ax.set_title(gene, fontsize=7, pad=2)
ax.set_xticks([]); ax.set_yticks([])
# All panels share UMAP coordinate axes. Only outer-edge panels
# show tick labels (constrained_layout handles this automatically).The panel_labels='lower' argument applies a, b, c, ..., i lowercase
labels regardless of the active preset.
fig, axes = small_multiples(2, 2, figsize='full_width', share='row',
panel_labels='lower')
for i, ses_q in enumerate(['Q1', 'Q2', 'Q3', 'Q4']):
ax = axes.ravel()[i]
# Light gray individual trajectories
for sid in students_in_ses[ses_q]:
ax.plot(grades, scores[sid], color=NEUTRAL['ci_band'], lw=0.4, alpha=0.15)
# Bold mean + CI
ax.plot(grades, mean_by_ses[ses_q], color=focal, lw=1.5)
ax.fill_between(grades, lo[ses_q], hi[ses_q], alpha=0.18)
ax.set_title(f'SES quartile {ses_q}', fontsize=8)share value |
Meaning |
|---|---|
'all' (default) |
sharex=True, sharey=True |
'row' |
each row shares y; each row's x is independent |
'col' |
each column shares x; each column's y is independent |
False |
no sharing |
The share kwarg is sugar over sharex and sharey. If you need
asymmetric sharing, pass them directly:
fig, axes = small_multiples(3, 3, sharex=True, sharey='row')| Use small_multiples when | Use compose_grid when |
|---|---|
| All panels are the same chart type | Panels are different chart types |
| Need automatic axis sharing | Need an asymmetric (mosaic) layout |
Panels indexed by axes[i, j] is fine |
Panels indexed by letter is clearer |
| Don't need width/height ratios | Need width_ratios / height_ratios |
Mosaic layout lets a single panel span multiple grid cells. Use a
multi-character mosaic string where the same letter marks the cells a
panel should occupy. The shape must be rectangular -- L-shapes raise
SSCIPlotsMosaicError.
fig, axd = compose_grid("""
AAB
CDB
""",
figsize='full_width',
width_ratios=[1, 1, 1.5],
height_ratios=[1.2, 1])
# Panel A spans the two leftmost cells of the top row (wide)
axd['A'].set_title('Main result')
# Panel B spans both rows of the rightmost column (tall)
axd['B'].set_title('Trajectory')
# Panels C and D are normal 1x1 panels in the bottom row
axd['C'].set_title('Subgroup analysis')
axd['D'].set_title('Validation cohort')Render: A is a wide panel (2 cols x 1 row), B is a tall panel
(1 col x 2 rows), C and D fill the remaining bottom-left cells. The
width_ratios=[1, 1, 1.5] makes the B column 50% wider than the others.
compose_grid pre-validates the mosaic and raises a clear error on
non-rectangular layouts. Matplotlib's built-in subplot_mosaic accepts
L-shapes but produces unpredictable axes alignment; this Skill rejects
them up front.
Failing example:
fig, axd = compose_grid("""
ABB
AXX
AAX
""")
# Raises:
# SSCIPlotsMosaicError: Label 'A' occupies a non-rectangular region
# at positions [(0,0), (1,0), (2,0), (2,1)].
# Fix: split 'A' into two adjacent rectangles (e.g. 'A1' + 'A2'),
# or use compose_subfigures() for free-form regions.Two ways to fix:
- Split into rectangles:
fig, axd = compose_grid("""
A1BB
A1XX
A2XX
""")
# A1 and A2 are now two distinct panels but visually contiguous.- Use
compose_subfigures(escape hatch for free-form regions):
fig, sfigs = compose_subfigures(2, 1, height_ratios=[2, 1])
# Top SubFigure can host the irregular layout; bottom hosts the remainder.width_ratios and height_ratios give per-column / per-row scaling.
The list length must match the mosaic's column count / row count.
fig, axd = compose_grid("ABCD",
figsize='full_width',
width_ratios=[1, 1, 2, 1])
# Panel C is 2x wider than A, B, D.For a 3x3 mosaic with a tall middle row:
fig, axd = compose_grid("AAA;BCD;EEE",
height_ratios=[1, 2, 1])When 2+ panels share the same color scale (correlation matrices across studies, choropleth panels across years, embedding plots across batches), one shared colorbar is correct -- one per panel is wrong.
| Pattern | Description | API |
|---|---|---|
| A: "Steal space" (default) | matplotlib carves a colorbar strip from the axes group. Easy; works with constrained_layout. |
add_shared_colorbar(fig, im, axes=axes.ravel().tolist()) |
| B: "Dedicated cax" | You create a dedicated colorbar axes via GridSpec; helper places into it. | add_shared_colorbar(fig, im, cax=cax) |
| C: "Per-subfig" | Each SubFigure gets its own colorbar. | call helper inside each sfigs[i] block |
from matplotlib.colors import TwoSlopeNorm
from ssci_style import (small_multiples, get_diverging_cmap,
add_shared_colorbar)
fig, axes = small_multiples(2, 2, figsize='full_width', share='all')
ims = []
for ax, mat in zip(axes.ravel(), correlation_mats_per_study):
im = ax.imshow(mat,
cmap=get_diverging_cmap('BuRd'),
norm=TwoSlopeNorm(vmin=-1, vcenter=0, vmax=1))
ax.set_xticks([]); ax.set_yticks([])
ims.append(im)
add_shared_colorbar(fig, ims[-1],
axes=axes.ravel().tolist(),
label='r',
shrink=0.7)The helper takes the last im mappable (all four have the same norm and
cmap, so any one works). axes=axes.ravel().tolist() tells matplotlib
which axes to "steal space" from.
When you want pixel-precise control over the colorbar's location (e.g., to leave a margin for figure-level annotations), use Pattern B:
from matplotlib.gridspec import GridSpec
fig = plt.figure(figsize='full_width')
gs = GridSpec(1, 4, figure=fig, width_ratios=[1, 1, 1, 0.05])
axes = [fig.add_subplot(gs[0, k]) for k in range(3)]
cax = fig.add_subplot(gs[0, 3])
for ax, mat in zip(axes, mats):
im = ax.imshow(mat,
cmap=get_diverging_cmap('BuRd'),
vmin=-1, vmax=1)
add_shared_colorbar(fig, im, cax=cax, label='Correlation $r$')If your data may exceed the colorbar limits (e.g., capped at +/-1 but
data could be -1.2), use extend='both':
add_shared_colorbar(fig, im, axes=axes.ravel().tolist(),
label='$r$', shrink=0.7, extend='both')
# Adds triangular extensions at both ends of the colorbar.extend accepts 'neither' (default), 'min', 'max', 'both'.
# DON'T do this:
for ax, mat in zip(axes.ravel(), mats):
im = ax.imshow(mat, cmap='viridis')
fig.colorbar(im, ax=ax) # 4 separate colorbars; visual clutterA 4-panel figure with 4 colorbars wastes space and creates visual noise. One shared colorbar communicates that all panels share the same scale.
Like shared colorbars: when 2+ panels use the same color encoding for groups (treatment vs control, intervention arms), one figure-level legend serves them all.
focal, ref = get_emphasis_pair('blue')
fig, axd = compose_grid("ABC", figsize='full_width')
for letter in 'ABC':
axd[letter].plot(x, y_treatment_per_panel[letter],
color=focal, lw=1.5, label='Treatment')
axd[letter].plot(x, y_control_per_panel[letter],
color=ref, lw=1.0, label='Control')
add_shared_legend(fig, axd['A'],
loc='lower center',
bbox_to_anchor=(0.5, -0.05),
ncol=2,
frameon=False)
# deduplicate=True (default) ensures the legend isn't repeated.fig.legend(bbox_to_anchor=...) does not always cooperate with
constrained_layout -- the legend can overlap the figure edges or be
clipped. Two solutions:
Solution 1 (default): use bbox_to_anchor and call
fig.savefig(bbox_inches='tight') when saving.
add_shared_legend(fig, axd['A'], loc='lower center',
bbox_to_anchor=(0.5, -0.05), ncol=2)
fig.savefig('figure.pdf', bbox_inches='tight')Solution 2: reserve a figure-fraction region with region=(x,y,w,h).
The helper then carves out that region (as axes('off')) so the legend
never overlaps the panels.
add_shared_legend(fig, axd['A'],
region=(0.0, 0.0, 1.0, 0.08), # bottom 8% of figure
loc='center')For a right-side legend column:
add_shared_legend(fig, axd['A'],
region=(0.82, 0.10, 0.16, 0.80),
loc='center left')If the panels don't all contain the same handles (e.g., panel A has treatment + control + reference, panel B has only treatment + control), pass the panel with the full set:
add_shared_legend(fig, axd['A']) # passes axes; helper extracts handlesOr pass a list of Artist handles directly:
from matplotlib.lines import Line2D
proxy_treat = Line2D([], [], color=focal, lw=1.5, label='Treatment')
proxy_control = Line2D([], [], color=ref, lw=1.0, label='Control')
proxy_ref = Line2D([], [], color='gray', lw=0.6, linestyle='--',
label='No effect')
add_shared_legend(fig, [proxy_treat, proxy_control, proxy_ref],
ncol=3, loc='lower center', bbox_to_anchor=(0.5, -0.04))By default, the helper deduplicates handles by label. If three panels
each have a 'Treatment' handle, the legend shows it once. To preserve
duplicates (rare), pass deduplicate=False.
compose_subfigures wraps matplotlib.Figure.subfigures. Each SubFigure
is a region of the Figure that can have its own suptitle, colorbar,
legend, and constrained_layout scope. This is the right tool when:
- You want per-region suptitles ("Main analysis" / "Sensitivity")
- You want per-region colorbars scoped to that region's panels only
- You want two visually distinct dashboards packaged as one Figure
from ssci_style import compose_subfigures, apply_style
apply_style(journal='nature')
fig, sfigs = compose_subfigures(2, 1,
figsize='full_width',
height_ratios=[1.4, 1])
# Top region: 2x2 main results
axL = sfigs[0].subplots(2, 2)
sfigs[0].suptitle('Main analysis', fontsize=9, fontweight='bold')
# ... plot into axL[i, j] ...
# Bottom region: 1x3 sensitivity analyses
axR = sfigs[1].subplots(1, 3)
sfigs[1].suptitle('Sensitivity analyses', fontsize=9, fontweight='bold')
# ... plot into axR[i] ...
# Figure-level suptitle
fig.suptitle('Figure 5. Effect of X on Y across analyses',
fontsize=10, fontweight='bold')| Need | Use |
|---|---|
| Different chart types in a rectangular layout | compose_grid |
| Cross-region spine alignment | compose_grid (or nested mosaic) |
| Per-region suptitle / colorbar / legend | compose_subfigures |
| Visually distinct "above the line / below the line" | compose_subfigures |
| L-shape or non-rectangular region | compose_subfigures (escape hatch) |
Important caveat: the spine alignment between SubFigures is not
guaranteed. If panel A in the top region and panel C in the bottom region
both show "Outcome" on the y-axis, their y-axes will not necessarily
align horizontally. For cross-region alignment, use compose_grid with
nested mosaics or matplotlib's GridSpecFromSubplotSpec.
fig, sfigs = compose_subfigures(1, 2, figsize='full_width')
axL = sfigs[0].subplots(2, 2)
# ... plot 4 heatmaps into axL ...
ims_L = [ax.imshow(m, cmap=get_diverging_cmap('BuRd'),
vmin=-1, vmax=1) for ax, m in zip(axL.ravel(), mats_left)]
add_shared_colorbar(sfigs[0], ims_L[-1], axes=axL.ravel().tolist(),
label='$r$', shrink=0.7)
sfigs[0].suptitle('Sample 1', fontsize=9, fontweight='bold')
axR = sfigs[1].subplots(2, 2)
ims_R = [ax.imshow(m, cmap=get_diverging_cmap('BuRd'),
vmin=-1, vmax=1) for ax, m in zip(axR.ravel(), mats_right)]
add_shared_colorbar(sfigs[1], ims_R[-1], axes=axR.ravel().tolist(),
label='$r$', shrink=0.7)
sfigs[1].suptitle('Sample 2', fontsize=9, fontweight='bold')Each SubFigure has its own colorbar -- visually clear that each side has its own (possibly different) sample.
Inset axes show a zoomed or contextual view inside a parent axes. Five canonical scenarios cover the bulk of top-SSCI usage.
The NEJM signature inset: a small panel in the upper-right showing the first year of survival in detail, while the main axes show the full follow-up.
from ssci_style import (apply_style, fig_size, get_emphasis_pair,
add_inset, add_inline_stats)
apply_style(journal='nejm')
fig, ax = plt.subplots(figsize=fig_size(9, 7))
focal, ref = get_emphasis_pair('navy')
ax.step(t, surv_treatment, where='post', color=focal, lw=1.2)
ax.step(t, surv_control, where='post', color=ref, lw=1.0)
ax.set_xlabel('Time (years)')
ax.set_ylabel('Survival probability')
ax.set_ylim(0, 1)
# Early-period zoom inset (NEJM idiom)
axins = add_inset(ax,
bbox=(0.55, 0.20, 0.40, 0.35),
xlim=(0, 1), ylim=(0.7, 1.0),
zoom_indicator=True)
axins.step(t, surv_treatment, where='post', color=focal, lw=1.0)
axins.step(t, surv_control, where='post', color=ref, lw=0.9)
axins.tick_params(labelsize=6)
add_inline_stats(ax,
[('HR', 0.65, (0.48, 0.88)),
('log-rank p', '< .001'),
('n', 1240)],
position='upper_right')zoom_indicator=True draws connector lines between the inset and the
region in the main axes -- a "magnifying glass" effect.
Regression discontinuity plots almost always accompany a McCrary manipulation test. The inset is small, no zoom indicator, just a distribution of the running variable around the cutoff.
fig, ax = plt.subplots(figsize='single_column')
# ... main RD scatter + polynomial fits + cutoff vertical line ...
axins = add_inset(ax,
bbox=(0.04, 0.65, 0.30, 0.30),
zoom_indicator=False)
axins.hist(running_var, bins=40, color=NEUTRAL['reference'], alpha=0.8)
axins.axvline(0, color='black', lw=0.6, linestyle='--')
axins.set_title('McCrary density', fontsize=6, pad=2)
axins.tick_params(labelsize=5)US county-level choropleth maps need separate Alaska and Hawaii inset panels (they don't share the continental US's projection).
fig, ax = plt.subplots(figsize=fig_size(18, 12))
# ... plot continental US in main axes ...
# Alaska
ax_ak = add_inset(ax, bbox=(0.02, 0.05, 0.20, 0.18),
zoom_indicator=False, hide_ticks=True)
# ... plot Alaska in ax_ak ...
# Hawaii
ax_hi = add_inset(ax, bbox=(0.24, 0.05, 0.10, 0.10),
zoom_indicator=False, hide_ticks=True)
# ... plot Hawaii in ax_hi ...Neuroimaging maps sometimes use an inset to highlight a specific ROI
with annotations. The inset is contextual (not zoomed); zoom_indicator
stays False.
axins = add_inset(ax, bbox=(0.70, 0.05, 0.25, 0.25), zoom_indicator=False)
axins.imshow(roi_mask, cmap='gray', interpolation='nearest')
axins.set_title('ROI: dlPFC', fontsize=6, pad=2)
axins.set_xticks([]); axins.set_yticks([])When a scatter has a high-density peak that the main axes can't resolve, an inset zooms into that peak.
axins = add_inset(ax, bbox=(0.10, 0.55, 0.35, 0.35),
xlim=peak_x_range, ylim=peak_y_range,
zoom_indicator=True)
axins.hexbin(x, y, gridsize=40, cmap='Blues',
extent=(*peak_x_range, *peak_y_range))Inset axes are not automatically accounted for by constrained_layout.
The inset's bbox is in axes fraction of the parent axes -- not figure
fraction. If you change the figure size after creating an inset, the
inset stays anchored to its parent axes (which is what you want), but
manual size tuning may be needed.
add_marginal_hist attaches a histogram (or KDE) on the top and right
of a scatter, showing the marginal distribution of x and y. This is the
classic "jointplot" layout, but as a helper that composes with any
matplotlib scatter.
add_marginal_hist is rare in top SSCI publications. The A0-9 audit of
real top-journal replication packages found it predominantly in
cognitive psychology (RT x accuracy plots) and a handful of ecology
papers. If the marginal distributions are not the central message,
prefer a single scatter panel and put the distributions in supplementary
material.
import matplotlib.pyplot as plt
from ssci_style import (apply_style, get_emphasis_pair,
add_marginal_hist, add_inline_stats)
apply_style(journal='apa')
focal, _ = get_emphasis_pair('blue')
fig, ax = plt.subplots(figsize=(5, 5))
ax.scatter(rt_ms, accuracy, s=10, alpha=0.4, color=focal)
ax.set_xlabel('Response time (ms)')
ax.set_ylabel('Accuracy (proportion correct)')
ax_top, ax_right = add_marginal_hist(ax, rt_ms, accuracy,
kind='hist', size='18%')
add_inline_stats(ax,
[('r', 0.42, (0.35, 0.48)),
('n', 480)],
position='upper_left')hide_inner=True (default) hides the inner tick labels on ax_top
(bottom edge tick labels) and ax_right (left edge tick labels), so
the marginals visually flow into the main axes.
ax_top, ax_right = add_marginal_hist(ax, x, y,
kind='kde', size='15%',
color=NEUTRAL['reference'])kind='kde' requires scipy. If scipy isn't available, kind='hist'
is always safe.
- The scatter is the main result AND the marginal shapes are themselves interpretable (bimodal RT distribution; truncated accuracy)
- The reader benefits from seeing distribution overlap (treatment vs control mean shift)
- This is a one-off "deep look" panel, not part of a multi-panel grid
- Forest plots, KM curves, time-series -- no marginal information to add
- Multi-panel small_multiples grids -- marginals add clutter to every panel
- The marginal distribution is uninteresting or known a priori
Use this checklist before submitting any multi-panel figure.
- Font size: all panels use the active preset's body font size
(do not pass per-panel
fontsizeoverrides for axis labels). - Palette: all panels share one
get_palette(n, NAME)call, or oneget_emphasis_pair()pair. Mixed palettes within a Figure look amateurish. - Axis label phrasing: identical variable across panels uses identical text -- "Score (1--5)" everywhere, never alternating with "Score" or "1--5 score".
- Panel labels: use
add_panel_labels(axes)orpanel_labels='auto'; do not hand-place withax.text. The preset controls case (Avsa) and size; don't override unless mixing presets. - Tick label size: leave to preset; don't manually
ax.tick_params(labelsize=...)per panel unless an inset specifically needs smaller ticks. - Inter-panel spacing: use
constrained_layout(the helpers' default); avoid hand-tuningsubplots_adjust(...)unless solving a specific overflow. - Shared scales: use
share='all','row', or'col'whenever panels show the same variable on the same scale. Sharing makes comparison instantaneous; not sharing forces the reader to re-read axes. - Mosaic shape: when using
compose_grid, the layout must be rectangular (the helper validates this;SSCIPlotsMosaicErrorwill point at the offending label). - Caption / suptitle: one figure-level caption that names all panels -- "Figure 3. (A) ..., (B) ..., (C) ...". APA forbids per-panel Figure numbers.
- Output: save once via
save_figure(fig, 'figure_3')-- never save per-panel files and reassemble in PowerPoint.
Quick lookup table:
| Pattern | Helper | Typical figsize |
|---|---|---|
| 1xN row | compose_grid("ABC") |
'full_width' |
| Nx1 column | compose_grid("A;B;C") |
'full_width' or column |
| 2x2 grid | compose_grid("AB;CD") |
'full_width' |
| Same-chart facets | small_multiples(n_rows, n_cols) |
'full_width' |
| Asymmetric panels | compose_grid("AAB;CDB") with width_ratios |
'full_width' |
| Two regions | compose_subfigures(2, 1, height_ratios=[2, 1]) |
'full_width' |
| Inset zoom | add_inset(ax, bbox, xlim, ylim, zoom_indicator=True) |
host figure's |
| Shared colorbar | add_shared_colorbar(fig, im, axes=...) |
host figure's |
| Shared legend | add_shared_legend(fig, axes[0], loc='lower center') |
host figure's |
| Marginal hist | add_marginal_hist(ax, x, y, kind='hist') |
square |
All figsize arguments accept either a FIGURE_SIZES key string
('full_width', 'single_column', 'event_study', ...) or an explicit
(width_inches, height_inches) tuple. See
scripts/ssci_style.py for the full list of named sizes.
The mosaic string has a label occupying a non-rectangular set of cells.
Fix: split the label into two adjacent rectangles, or use
compose_subfigures().
If panel_labels='auto' and no labels show, check:
- Did you pass
panel_labels=Falseexplicitly? - Is the figure too small for the labels' default position (
x=-0.05, y=1.08in axes fraction)? Trypanel_labels='auto'and a larger figsize.
To force labels with explicit positions, call add_panel_labels(axes, x=-0.08, y=1.05) after creating the panels.
constrained_layout may not account for a bbox_to_anchor legend
outside the figure. Use the region argument of add_shared_legend
to physically reserve space, or save with bbox_inches='tight'.
add_inset uses axes-fraction coordinates (0..1 within the parent
axes). If your bbox has negative numbers or values > 1, the inset will
extend outside the parent. This may be intentional (e.g., a callout box
above the axes) but most often is a bbox typo.
If 4 panels have different vmin/vmax ranges, the shared colorbar
defaults to the last mappable's range. Solution: explicitly set all
panels to the same range (or use a global norm):
norm = TwoSlopeNorm(vmin=-1, vcenter=0, vmax=1)
ims = [ax.imshow(m, cmap=cmap, norm=norm) for ax, m in zip(axes.ravel(), mats)]
add_shared_colorbar(fig, ims[0], axes=axes.ravel().tolist(), label='$r$')This is by design -- SubFigures have independent constrained_layout
scopes. For cross-region alignment, switch to compose_grid with a
nested mosaic or use GridSpecFromSubplotSpec.
- For chart-specific composition recipes (e.g., 2x2 RCT panel, 3x3 small
multiples for replication studies), see the
Journal-grade upgrade checklistsubsection at the end of each chart section inchart-types-core.md,chart-types-models.md,chart-types-causal-econ.md, andchart-types-applied.md. - For visual-complexity elements that frequently appear in multi-panel
figures (inline stats, reference lines, two-tone emphasis), see
complexity-elements-catalog.md. - For the helper signatures themselves, see
scripts/ssci_style.py-- the SSOT for all 27 public helpers.