Skip to content

Latest commit

 

History

History
966 lines (736 loc) · 32.9 KB

File metadata and controls

966 lines (736 loc) · 32.9 KB

Multi-Panel Composition Guide

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.


§1. Overview & Decision Tree

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)

§2. Basic 1xN and Nx1

The simplest multi-panel cases. Use compose_grid with a one-row or one-column mosaic string.

§2.1 1x3 row (three panels horizontal)

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).

§2.2 Nx1 column (three panels vertical)

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.

§2.3 Shared axes shortcut

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.

§3. Basic 2x2 grid

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.

§3.1 2x2 RCT outcome panel template

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')

§4. small_multiples(n_rows, n_cols)

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.

§4.1 3x3 small multiples ("9 plots stacked together")

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.

§4.2 2x2 facet with grouped data

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)

§4.3 share parameter semantics

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')

§4.4 small_multiples vs compose_grid

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

§5. Asymmetric mosaic (compose_grid("AAB;CDB"))

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.

§5.1 6-panel dashboard (Nature-style)

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.

§5.2 The rectangular-mosaic invariant

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:

  1. Split into rectangles:
fig, axd = compose_grid("""
    A1BB
    A1XX
    A2XX
    """)
# A1 and A2 are now two distinct panels but visually contiguous.
  1. 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.

§5.3 Width and height ratios

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])

§6. Shared colorbar (add_shared_colorbar)

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.

§6.1 Three idioms for placement

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

§6.2 2x2 correlation-matrix grid with shared r colorbar

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.

§6.3 Dedicated cax for precise placement

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$')

§6.4 Extending the colorbar past the data range

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'.

§6.5 Avoid: one colorbar per panel

# 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 clutter

A 4-panel figure with 4 colorbars wastes space and creates visual noise. One shared colorbar communicates that all panels share the same scale.


§7. Shared legend (add_shared_legend)

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.

§7.1 Basic shared legend below figure

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.

§7.2 constrained_layout caveat and the region escape hatch

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')

§7.3 Passing handles directly

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 handles

Or 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))

§7.4 deduplicate=True

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.


§8. Subfigures (compose_subfigures)

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

§8.1 Two-region Nature-style 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')

§8.2 When to use compose_subfigures vs compose_grid

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.

§8.3 Per-subfigure colorbar

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.


§9. Inset zoom (add_inset)

Inset axes show a zoomed or contextual view inside a parent axes. Five canonical scenarios cover the bulk of top-SSCI usage.

§9.1 Scenario 1: NEJM Kaplan-Meier early zoom

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.

§9.2 Scenario 2: RD plot McCrary density inset

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)

§9.3 Scenario 3: Choropleth AK/HI inset

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 ...

§9.4 Scenario 4: Brain map ROI annotation

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([])

§9.5 Scenario 5: Bivariate density spotlight

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))

§9.6 constrained_layout caveat

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.


§10. Marginal histogram on scatter (add_marginal_hist)

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.

§10.1 Single recommended scenario: RT x accuracy

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.

§10.2 KDE marginal (requires scipy)

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.

§10.3 When to use it

  • 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

§10.4 When to avoid it

  • 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

§11. Cross-panel consistency checklist

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 fontsize overrides for axis labels).
  • Palette: all panels share one get_palette(n, NAME) call, or one get_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) or panel_labels='auto'; do not hand-place with ax.text. The preset controls case (A vs a) 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-tuning subplots_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; SSCIPlotsMosaicError will 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.

§12. Composition patterns at a glance

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.


§13. Common errors and recovery

§13.1 SSCIPlotsMosaicError: non-rectangular region

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().

§13.2 panel_labels not appearing

If panel_labels='auto' and no labels show, check:

  1. Did you pass panel_labels=False explicitly?
  2. Is the figure too small for the labels' default position (x=-0.05, y=1.08 in axes fraction)? Try panel_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.

§13.3 Shared legend clipped at figure edge

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'.

§13.4 Inset is outside the parent axes

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.

§13.5 Shared colorbar uses the wrong scale

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$')

§13.6 SubFigure spines don't align between regions

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.


§14. Where to go next

  • For chart-specific composition recipes (e.g., 2x2 RCT panel, 3x3 small multiples for replication studies), see the Journal-grade upgrade checklist subsection at the end of each chart section in chart-types-core.md, chart-types-models.md, chart-types-causal-econ.md, and chart-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.