Publication-Ready Figures with neuro_py¶
Build cleaner, sharper neuroscience figures that drop directly into manuscripts with the right physical size and typography.
This tutorial shows how to combine:
- set_plotting_defaults(workflow) to match journal/document typography
- set_size(width, fraction, subplots, ratio) to get exact final figure dimensions
By the end, you’ll have reusable plotting patterns for single-panel, multi-panel, and workflow-specific exports (nature, word, latex).
import matplotlib.pyplot as plt
import numpy as np
from neuro_py.plotting.figure_helpers import set_plotting_defaults, set_size
SAVE_FIG = False
Available Workflows¶
| Workflow | Font | Font Size | Target |
|---|---|---|---|
"nature" |
Helvetica / Arial | 7pt | Nature, Science, Cell, Neuron |
"word" |
Times New Roman | 11pt | Word / Google Doc manuscripts |
"latex" |
CMU Serif / Latin Modern | 10pt | LaTeX article submissions |
Available Width Presets¶
| Preset | Width (pt) | Width (mm) | Use case |
|---|---|---|---|
nature_single |
255 | 90mm | Nature single column |
nature_double |
510 | 180mm | Nature double column |
science_single |
162 | 57mm | Science single column |
science_double |
343 | 121mm | Science double column |
science_triple |
521 | 184mm | Science full width |
cell_single |
241 | 85mm | Cell/Neuron single column |
cell_1p5 |
323 | 114mm | Cell/Neuron 1.5 column |
cell_double |
493 | 174mm | Cell/Neuron double column |
single_col |
255 | 90mm | Generic single column |
double_col |
510 | 180mm | Generic double column |
textwidth |
418 | 147mm | LaTeX article textwidth |
thesis |
427 | 151mm | Thesis textwidth |
beamer |
307 | 108mm | Beamer presentation |
1. Basic Usage — Nature Workflow¶
set_plotting_defaults("nature")
# Single-column figure sized for Nature
fig, ax = plt.subplots(figsize=set_size("nature_single"), dpi=200)
x = np.linspace(0, 2 * np.pi, 300)
signal_a = np.sin(x)
signal_b = np.cos(x)
ax.plot(x, signal_a, label="Condition A", lw=1.6)
ax.plot(x, signal_b, label="Condition B", lw=1.6)
ax.fill_between(x, signal_a, signal_b, alpha=0.12, color="0.35", linewidth=0)
ax.set_xlabel("Time (s)")
ax.set_ylabel("Amplitude")
ax.set_title("Example single-column panel", loc="left", fontweight="bold")
ax.axhline(0, color="0.7", lw=0.8, zorder=0)
ax.margins(x=0)
ax.legend(frameon=False, ncol=2, loc="upper right")
fig.tight_layout()
if SAVE_FIG:
fig.savefig("nature_single.svg", bbox_inches="tight")
plt.show()

2. Multi-Panel Figure — Double Column¶
set_plotting_defaults("nature")
# set_size adjusts height automatically for subplot grids
fig, axes = plt.subplots(
1, 2, figsize=set_size("nature_double", subplots=(1, 2)), dpi=200
)
np.random.seed(42)
t = np.linspace(0, 1, 200)
spikes = np.random.poisson(5, size=(20, 200))
# Panel A — raster plot
for i, train in enumerate(spikes):
spike_times = t[train > 0]
axes[0].vlines(spike_times, i + 0.5, i + 1.5, linewidth=0.5, alpha=0.9)
axes[0].set_xlabel("Time (s)")
axes[0].set_ylabel("Neuron")
axes[0].set_title("A Spike Raster", loc="left", fontweight="bold")
# Panel B — firing rate with smoothed trend
rate = spikes.mean(axis=0)
smooth = np.convolve(rate, np.ones(15) / 15, mode="same")
axes[1].plot(t, rate, alpha=0.35, lw=1.0, label="Raw")
axes[1].plot(t, smooth, lw=1.8, label="Smoothed")
axes[1].set_xlabel("Time (s)")
axes[1].set_ylabel("Firing Rate (Hz)")
axes[1].set_title("B Population Rate", loc="left", fontweight="bold")
axes[1].legend(frameon=False, loc="upper right")
for a in axes:
a.axvline(0.5, ls="--", color="0.75", lw=0.8)
a.margins(x=0)
fig.tight_layout()
if SAVE_FIG:
fig.savefig("nature_double_panel.svg", bbox_inches="tight")
plt.show()

The subplots=(1, 2) argument to set_size adjusts the figure height so that each panel has a golden ratio aspect ratio rather than the whole figure.
3. Using fraction — Half-Width Figures¶
set_plotting_defaults("nature")
# Half of a double column — useful for inset-style figures
fig, ax = plt.subplots(figsize=set_size("nature_double", fraction=0.5), dpi=200)
np.random.seed(0)
data = [np.random.normal(loc, 0.5, 50) for loc in [1, 2, 3]]
bp = ax.boxplot(data, tick_labels=["Pre", "Task", "Post"], patch_artist=True)
for patch, face in zip(bp["boxes"], ["#88CCEE", "#CC6677", "#44AA99"]):
patch.set_facecolor(face)
patch.set_alpha(0.5)
ax.set_ylabel("Firing Rate (Hz)")
ax.set_title("Half-width summary panel", loc="left", fontweight="bold")
ax.grid(axis="y", alpha=0.2, lw=0.5)
fig.tight_layout()
if SAVE_FIG:
fig.savefig("nature_half.svg", bbox_inches="tight")
plt.show()

In practice, fraction is the main knob for fitting figures to your layout.
A good workflow is to start with fraction=1.0 and reduce until axis labels
occupy a comfortable proportion of the figure width:
# Start here and adjust until it looks right
fig, ax = plt.subplots(figsize=set_size("nature_double", fraction=0.6))
The font sizes stay fixed — only the figure dimensions change — so reducing
fraction makes labels appear relatively larger compared to the plot area.
set_plotting_defaults("nature")
# Tip: adjust fraction until axis labels fill the figure naturally
fig, ax = plt.subplots(figsize=set_size("nature_double", fraction=0.35), dpi=200)
np.random.seed(0)
data = [np.random.normal(loc, 0.5, 50) for loc in [1, 2, 3]]
bp = ax.boxplot(data, tick_labels=["Pre", "Task", "Post"], patch_artist=True)
for patch, face in zip(bp["boxes"], ["#88CCEE", "#CC6677", "#44AA99"]):
patch.set_facecolor(face)
patch.set_alpha(0.5)
ax.set_ylabel("Firing Rate (Hz)")
ax.set_title("Half-width summary panel", loc="left", fontweight="bold")
ax.grid(axis="y", alpha=0.2, lw=0.5)
fig.tight_layout()
if SAVE_FIG:
fig.savefig("nature_half.svg", bbox_inches="tight")
plt.show()

4. Overriding the Aspect Ratio¶
set_plotting_defaults("nature")
# Square figure — useful for place field maps, correlation matrices
fig, axes = plt.subplots(
1, 2, figsize=set_size("nature_double", ratio=1.0, subplots=(1, 2)), dpi=200
)
np.random.seed(1)
place_field = np.random.rand(20, 20)
corr_matrix = np.corrcoef(np.random.rand(10, 50))
# Panel A — place field heatmap
im1 = axes[0].imshow(place_field, cmap="hot", aspect="equal")
axes[0].set_xlabel("Position X (cm)")
axes[0].set_ylabel("Position Y (cm)")
axes[0].set_title("A Place Field", loc="left", fontweight="bold")
plt.colorbar(im1, ax=axes[0], label="Rate (Hz)")
# Panel B — correlation matrix
im2 = axes[1].imshow(corr_matrix, cmap="RdBu_r", vmin=-1, vmax=1, aspect="equal")
axes[1].set_title("B Correlation Matrix", loc="left", fontweight="bold")
plt.colorbar(im2, ax=axes[1], label="Correlation")
fig.tight_layout()
if SAVE_FIG:
fig.savefig("nature_square.svg")
plt.show()

The default golden ratio (~0.618) is ideal for line plots, but ratio=1.0 gives square panels which is better for spatial maps and matrices.
5. Word Workflow¶
set_plotting_defaults("word")
# single_col maps to nature_single width — good default for Word figures
fig, ax = plt.subplots(figsize=set_size("single_col"), dpi=200)
np.random.seed(3)
epochs = ["Pre-sleep", "Task", "Post-sleep"]
means = [2.1, 8.4, 3.7]
sems = [0.3, 0.6, 0.4]
ax.bar(epochs, means, yerr=sems, capsize=3, color=["#0072B2", "#D55E00", "#009E73"])
ax.set_ylabel("Firing Rate (Hz)")
ax.set_title("Mean Firing Rate by Epoch")
if SAVE_FIG:
fig.savefig("word_bar.svg")
plt.show()

6. LaTeX Workflow¶
set_plotting_defaults("latex")
# textwidth matches \textwidth in a standard article class document
fig, ax = plt.subplots(figsize=set_size("textwidth"), dpi=200)
np.random.seed(5)
t = np.linspace(0, 2, 500)
theta = np.cumsum(np.random.randn(500)) * 0.1
power = np.abs(np.fft.rfft(theta)) ** 2
freqs = np.fft.rfftfreq(500, d=1 / 250)
ax.semilogy(freqs[1:], power[1:])
ax.set_xlabel("Frequency (Hz)")
ax.set_ylabel("Power ($\\mu V^2$/Hz)")
ax.set_title("LFP Power Spectrum")
ax.set_xlim(0, 100)
if SAVE_FIG:
fig.savefig("latex_spectrum.svg")
plt.show()

For half-width figures in a LaTeX document (e.g. two figures side by side with \includegraphics[width=0.5\textwidth]):
fig, ax = plt.subplots(figsize=set_size("textwidth", fraction=0.5))
7. Comparing Workflows Side by Side¶
To make font differences visible in a single figure, this demo applies workflow-specific font families directly to each panel (instead of relying only on global style settings).
np.random.seed(7)
x = np.linspace(0, 4 * np.pi, 200)
y1 = np.sin(x) + np.random.normal(0, 0.1, 200)
y2 = np.cos(x) + np.random.normal(0, 0.1, 200)
workflows = ["nature", "word", "latex"]
demo_fonts = {
"nature": "Helvetica",
"word": "Times New Roman",
"latex": "Latin Modern Roman",
}
ylim = (min(y1.min(), y2.min()) - 0.2, max(y1.max(), y2.max()) + 0.2)
fig, axes = plt.subplots(
1, 3, figsize=set_size("nature_double", subplots=(1, 3), ratio=1), dpi=200
)
for ax, workflow in zip(axes, workflows):
set_plotting_defaults(workflow)
font = demo_fonts[workflow]
ax.plot(x, y1, label="Condition A", lw=1.4)
ax.plot(x, y2, label="Condition B", lw=1.4)
ax.set_title(workflow.capitalize(), fontweight="bold", fontfamily=font)
ax.set_xlabel("Time (s)", fontfamily=font)
ax.set_ylim(*ylim)
ax.margins(x=0)
ax.grid(alpha=0.18, lw=0.5)
for tick in ax.get_xticklabels() + ax.get_yticklabels():
tick.set_fontfamily(font)
axes[0].set_ylabel("Signal", fontfamily=demo_fonts[workflows[0]])
legend = axes[-1].legend(frameon=False, loc="upper right", bbox_to_anchor=(1.02, 1.0))
for text in legend.get_texts():
text.set_fontfamily(demo_fonts[workflows[-1]])
fig.suptitle("Workflow style comparison on identical data", y=1.01)
fig.tight_layout()
if SAVE_FIG:
fig.savefig("compare_workflows.svg", bbox_inches="tight")
plt.show()

8. Passing a Custom Width¶
If your journal has a specific column width not in the presets, pass the width in points directly. To convert from mm: width_pt = width_mm * 2.8346.
set_plotting_defaults("nature")
# eLife single column: 87.6 mm -> points
elife_single_pt = 87.6 * 2.8346
fig_w, fig_h = set_size(elife_single_pt)
fig, ax = plt.subplots(figsize=(fig_w, fig_h), dpi=200)
trace = np.random.default_rng(11).random(100).cumsum()
ax.plot(np.linspace(0, 1, 100), trace, lw=1.5)
ax.set_xlabel("Time (s)")
ax.set_ylabel("Cumulative Signal")
ax.set_title("Custom-width panel", loc="left", fontweight="bold")
ax.text(
0.02,
0.95,
f"Size: {fig_w:.2f} x {fig_h:.2f} in",
transform=ax.transAxes,
va="top",
fontsize=6,
color="0.35",
)
fig.tight_layout()
plt.show()

9. Dark Theme with neuro_py_dark¶
For talks, posters, or dark slide decks, you can apply the provided neuro_py_dark.mplstyle file in a local style context without changing the rest of the notebook defaults.
set_plotting_defaults("dark")
fig, ax = plt.subplots(figsize=set_size("nature_single"), dpi=200)
rng = np.random.default_rng(21)
x = np.linspace(0, 10, 400)
signal = np.sin(2 * np.pi * 0.8 * x) + 0.25 * rng.normal(size=x.size)
ax.plot(x, signal, label="LFP-like trace")
signal = np.cos(2 * np.pi * 0.8 * x) + 0.25 * rng.normal(size=x.size)
ax.plot(x, signal - 3, label="LFP-like trace")
signal = np.sin(2 * np.pi * 0.8 * x) + 0.25 * rng.normal(size=x.size)
ax.plot(x, signal - 6, label="LFP-like trace")
ax.set_xlabel("Time (s)")
ax.set_ylabel("Amplitude (a.u.)")
ax.set_title("Dark-theme figure with neuro_py_dark", loc="left", fontweight="bold")
ax.margins(x=0)
fig.tight_layout()
if SAVE_FIG:
fig.savefig("dark_style_demo.svg", bbox_inches="tight")
plt.show()

Quick Recipe Card¶
Use this as a copy/paste checklist when preparing final figures:
# 1) Pick your destination workflow
set_plotting_defaults("nature") # or "word", "latex"
# 2) Match figure width to target layout
fig, ax = plt.subplots(figsize=set_size("nature_single"))
# 3) Multi-panel layout (height auto-adjusts)
fig, axes = plt.subplots(2, 3, figsize=set_size("nature_double", subplots=(2, 3)))
# 4) For maps/matrices, force square axes
fig, ax = plt.subplots(figsize=set_size("nature_single", ratio=1.0))
# 5) Half-width panel for insets
fig, ax = plt.subplots(figsize=set_size("nature_double", fraction=0.5))
# 6) Save vector output for publication
fig.savefig("figure.svg", bbox_inches="tight")
Tip: if labels look too small/large in your manuscript, verify that the figure is inserted at its intended physical width (not scaled in the editor).