Skip to content

Gallery

Figures you could drop into a manuscript today. Every image below is produced by the code beneath it, sized for a Nature single column (89 mm) and exported with embedded fonts. Click a thumbnail to jump to the code.

Synthetic data, real output

All datasets are simulated with a fixed random seed so every figure is reproducible. Nothing on this page is retouched: the images are the library's output, regenerated each time the documentation is built.

Dose response

Cell viability after 48 h of drug exposure. Every well is visible, the box summarises it, and .compare() runs Mann–Whitney tests against the vehicle with a Holm correction. A light-to-dark ramp encodes the dose without needing a legend.

Box plots of cell viability at four doses with all points and significance stars.

Show code

import numpy as np
import pandas as pd
import SecretPlots as sp

sp.use(preset="nature")
rng = np.random.default_rng(7)

doses = ["Vehicle", "1 µM", "5 µM", "10 µM"]
viability = pd.DataFrame({
    "dose": np.repeat(doses, 12),
    "viability": np.concatenate(
        [rng.normal(mean, 7, 12) for mean in (100, 91, 70, 46)]
    ),
})

(
    sp.box(viability, x="dose", y="viability")
    .points()
    .compare(ref="Vehicle", correction="holm", format="stars")
    .colors(dict(zip(doses, ["#C6DBEF", "#6BAED6", "#2171B5", "#08306B"])))
    .labels(x="", y="Cell viability (% of vehicle)")
)

Options used: .points(), .compare(), .colors().

Tumour growth

Tumour volume of eight mice per arm on a log scale. Thin lines are individual animals (units=), the band is the 95% confidence interval of the mean, and end labels replace the legend.

Tumour growth curves for three treatment arms with individual mice and confidence bands.

Show code

import numpy as np
import pandas as pd
import SecretPlots as sp

sp.use(preset="nature")
rng = np.random.default_rng(11)

days = np.arange(0, 22, 2)
arms = {"Vehicle": 0.16, "Drug A": 0.11, "Drug A + B": 0.05}
rows = []
for arm, rate in arms.items():
    for mouse in range(8):
        volume = 90 * np.exp(rate * days) * rng.lognormal(0, 0.14, days.size)
        rows += [
            {"day": d, "volume": v, "arm": arm, "mouse": f"{arm}-{mouse}"}
            for d, v in zip(days, volume)
        ]
tumours = pd.DataFrame(rows)

(
    sp.line(tumours, x="day", y="volume", hue="arm", units="mouse", error="ci")
    .endlabels()
    .log("y")
    .colors(["#555555", "#E69F00", "#0072B2"])
    .labels(x="Days after implantation", y="Tumour volume (mm³)")
)

Volcano plot

Differential expression of 2,000 genes. Grey points fall inside the fold-change and significance thresholds; reference lines mark both cut-offs.

Volcano plot with up- and down-regulated genes in red and blue.

Show code

import numpy as np
import pandas as pd
import SecretPlots as sp

sp.use(preset="nature")
rng = np.random.default_rng(3)

n = 2000
lfc = rng.normal(0, 1.1, n)
log_p = np.abs(lfc) ** 1.6 * rng.gamma(2.2, 1.0, n) + rng.uniform(0, 0.3, n)
genes = pd.DataFrame({"lfc": lfc, "log_p": log_p})
significant = genes["log_p"] > 2
genes["call"] = np.select(
    [significant & (genes["lfc"] > 1), significant & (genes["lfc"] < -1)],
    ["Up", "Down"],
    "Not significant",
)

(
    sp.scatter(genes, x="lfc", y="log_p", hue="call")
    .colors({"Up": "#C0392B", "Down": "#2471A3", "Not significant": "#C8C8C8"})
    .refline(-1, 1, axis="x", linestyle=":")
    .refline(2, axis="y", linestyle=":")
    .theme(marker_size=3, grid="none")
    .labels(x="log₂ fold change", y="−log₁₀ P")
)

Expression heatmap

Sixteen genes in four pathways after myocardial infarction (MI). Rows are z-scored, clustered within each pathway, and annotated with a condition track. Gene names are italic, as journals require.

Clustered expression heatmap with pathway groups and a condition track.

Show code

import numpy as np
import pandas as pd
import SecretPlots as sp

sp.use(preset="nature")
rng = np.random.default_rng(5)

genes = ["GATA4", "NKX2-5", "TBX5", "HAND2", "MYH6", "MYL7", "TNNT2", "ACTC1",
         "CDK1", "MKI67", "TOP2A", "CCNB1", "COL1A1", "COL3A1", "POSTN", "FN1"]
samples = [f"Sham {i}" for i in range(1, 5)] + [f"MI {i}" for i in range(1, 5)]
values = rng.normal(5, 0.7, (16, 8))
values[:8, 4:] -= 1.6     # cardiac programme down after MI
values[8:12, 4:] += 1.2   # proliferation up
values[12:, 4:] += 2.2    # fibrosis up
expression = pd.DataFrame(values, index=pd.Index(genes, name="gene"), columns=samples)
expression["pathway"] = (
    ["Cardiac TF"] * 4 + ["Sarcomere"] * 4 + ["Cell cycle"] * 4 + ["Fibrosis"] * 4
)

(
    sp.heatmap(expression)
    .scale("row")
    .groups(rows="pathway")
    .cluster(cols=False)
    .tracks(cols={"Condition": ["Sham"] * 4 + ["MI"] * 4})
    .cmap("tol_sunset", center=0)
    .colorbar("Row z-score")
    .ticklabels(italic=True)
)

Correlation matrix

Pairwise Pearson correlations between clinical variables. The redundant upper triangle is masked and the diverging colormap is centred on zero.

Lower-triangle correlation matrix with coefficients printed in each cell.

Show code

import numpy as np
import pandas as pd
import SecretPlots as sp

sp.use(preset="nature")
rng = np.random.default_rng(21)

variables = ["Age", "BMI", "SBP", "LDL", "HbA1c", "CRP", "eGFR"]
latent = rng.normal(size=(7, 3))
covariance = latent @ latent.T + np.eye(7)
cohort = pd.DataFrame(
    rng.multivariate_normal(np.zeros(7), covariance, 300), columns=variables
)

(
    sp.heatmap(cohort.corr(), square=True)
    .mask("upper")
    .cmap("tol_bu_rd", center=0, vmin=-1, vmax=1)
    .values(".2f")
    .colorbar("Pearson r")
)

Raincloud

Reaction times in a Stroop task. The raincloud combines a half-violin, a box and the raw trials, the honest way to show skewed data.

Raincloud plot of reaction times in three Stroop conditions.

Show code

import numpy as np
import pandas as pd
import SecretPlots as sp

sp.use(preset="nature")
rng = np.random.default_rng(42)

conditions = ["Congruent", "Neutral", "Incongruent"]
stroop = pd.DataFrame({
    "condition": np.repeat(conditions, 60),
    "rt": np.concatenate(
        [rng.lognormal(np.log(mu), 0.18, 60) for mu in (480, 515, 610)]
    ),
})

(
    sp.raincloud(stroop, x="condition", y="rt")
    .compare(ref="Congruent", format="stars")
    .colors(dict(zip(conditions, ["#44AA99", "#DDCC77", "#CC6677"])))
    .labels(x="Reaction time (ms)", y="")  # horizontal: values run along x
)

Split violins

A behavioural score by genotype, with females and males sharing each violin. Dashed lines mark the quartiles.

Split violins comparing females and males across three genotypes.

Show code

import numpy as np
import pandas as pd
import SecretPlots as sp

sp.use(preset="nature")
rng = np.random.default_rng(8)

behaviour = pd.DataFrame({
    "genotype": np.tile(np.repeat(["WT", "Het", "KO"], 40), 2),
    "sex": np.repeat(["Female", "Male"], 120),
})
effect = behaviour["genotype"].map({"WT": 0, "Het": 0.6, "KO": 1.6})
behaviour["score"] = rng.normal(0, 1, 240) + effect + 0.5 * (behaviour["sex"] == "Male")

(
    sp.violin(behaviour, x="genotype", y="score", hue="sex", split=True, inner="quartiles")
    .colors(["#CC79A7", "#009E73"])
    .labels(x="", y="Anxiety score (z)")
)

qPCR fold change

Relative expression of four cytokines in wild-type and knockout macrophages (mean ± SD, n = 6). The reference line at 1 marks no change.

Grouped bars of relative expression for four cytokine genes.

Show code

import numpy as np
import pandas as pd
import SecretPlots as sp

sp.use(preset="nature")
rng = np.random.default_rng(13)

induction = {"Il6": 6.0, "Tnf": 4.0, "Il1b": 3.0, "Cxcl10": 8.0}
qpcr = pd.DataFrame(
    [
        {"gene": gene, "genotype": genotype,
         "fold": fold * (0.4 if genotype == "KO" else 1) * rng.lognormal(0, 0.18)}
        for gene, fold in induction.items()
        for genotype in ("WT", "KO")
        for _ in range(6)
    ]
)

(
    sp.bar(qpcr, x="gene", y="fold", hue="genotype", error="sd")
    .refline(1, linestyle=":")
    .colors(["#4D4D4D", "#E69F00"])
    .ticklabels(italic=True)
    .labels(x="", y="Relative expression (2^−ΔΔCt)")
)

Pharmacokinetics

Plasma concentration after a single oral dose at two dose levels (mean ± SD, n = 10), on a log axis where first-order elimination is a straight line.

Semi-logarithmic plasma concentration curves with error bars and markers.

Show code

import numpy as np
import pandas as pd
import SecretPlots as sp

sp.use(preset="nature")
rng = np.random.default_rng(17)

hours = np.array([0.25, 0.5, 1, 2, 4, 6, 8, 12, 24])
rows = []
for dose, scale in (("10 mg kg⁻¹", 1.0), ("30 mg kg⁻¹", 3.0)):
    for subject in range(10):
        ka, ke = rng.normal(1.6, 0.2), rng.normal(0.18, 0.02)
        conc = scale * 900 * ka / (ka - ke) * (np.exp(-ke * hours) - np.exp(-ka * hours))
        rows += [{"hours": h, "conc": c * rng.lognormal(0, 0.12), "dose": dose}
                 for h, c in zip(hours, conc)]
pk = pd.DataFrame(rows)

(
    sp.line(pk, x="hours", y="conc", hue="dose", error="sd")
    .errorbars("bars")
    .markers()
    .log("y")
    .colors(["#56B4E9", "#0072B2"])
    .labels(x="Time after dose (h)", y="Plasma concentration (ng mL⁻¹)")
)

Regression

Hepatic and muscle lipid content against BMI with one least-squares fit per tissue, its 95% confidence band, and the fitted equation with R².

Scatter plot with two linear fits, confidence bands and equations.

Show code

import numpy as np
import pandas as pd
import SecretPlots as sp

sp.use(preset="nature")
rng = np.random.default_rng(29)

biopsies = pd.DataFrame({
    "bmi": rng.uniform(19, 36, 140),
    "tissue": rng.choice(["Liver", "Muscle"], 140),
})
offset = np.where(biopsies["tissue"] == "Liver", 6, 0)
slope = np.where(biopsies["tissue"] == "Liver", 0.82, 0.45)
biopsies["lipid"] = slope * biopsies["bmi"] + offset + rng.normal(0, 2.2, 140)

(
    sp.scatter(biopsies, x="bmi", y="lipid", hue="tissue")
    .fit("linear", stats=True)
    .colors(["#AA3377", "#4477AA"])
    .labels(x="BMI (kg m⁻²)", y="Lipid content (mg g⁻¹)")
)

Biomarker distribution

Serum biomarker levels in healthy donors and patients, normalised to densities so cohorts of different sizes compare fairly. Group sizes are added to the legend automatically.

Overlaid histograms with smooth density curves for two cohorts.

Show code

import numpy as np
import pandas as pd
import SecretPlots as sp

sp.use(preset="nature")
rng = np.random.default_rng(31)

serum = pd.DataFrame({
    "cohort": np.repeat(["Healthy", "Patients"], [400, 250]),
    "level": np.concatenate([rng.normal(1.2, 0.25, 400), rng.normal(1.75, 0.32, 250)]),
})

(
    sp.histogram(serum, x="level", hue="cohort", stat="density")
    .kde()
    .show_n()
    .colors(["#56B4E9", "#D55E00"])
    .labels(x="Serum IL-6 (log₁₀ pg mL⁻¹)", y="Density")
)

Temperature anomaly

Annual global temperature anomaly relative to a 1951–1980 baseline. .split() colours the area by sign, so warm and cool years read at a glance.

Temperature anomaly area chart coloured red above zero and blue below.

Show code

import numpy as np
import pandas as pd
import SecretPlots as sp

sp.use(preset="nature")
rng = np.random.default_rng(1880)

years = np.arange(1880, 2025)
trend = -0.25 + 1.45 * ((years - 1880) / 144) ** 3
climate = pd.DataFrame({
    "year": years,
    "anomaly": trend + 0.12 * np.sin(years / 3.7) + rng.normal(0, 0.09, years.size),
})

(
    sp.fill_between(climate, x="year", y1="anomaly", y2=0)
    .split(colors=("#D6604D", "#4393C3"), labels=("Warmer", "Cooler"))
    .labels(x="", y="Temperature anomaly (°C)")
)

Cell composition

Share of immune cell types in blood after stimulation. A 100% stacked area keeps the total fixed so shifts in composition are easy to follow.

Stacked area chart of immune cell fractions over 48 hours.

Show code

import numpy as np
import pandas as pd
import SecretPlots as sp

sp.use(preset="nature")
rng = np.random.default_rng(48)

hours = np.arange(0, 49, 4)
cell_types = {"T cells": (42, -0.35), "B cells": (24, -0.05),
              "Monocytes": (19, 0.42), "NK cells": (15, -0.02)}
blood = pd.DataFrame(
    [
        {"hours": h, "cell_type": name,
         "cells": max(1.0, base + slope * h + rng.normal(0, 0.8))}
        for name, (base, slope) in cell_types.items()
        for h in hours
    ]
)

(
    sp.area(blood, x="hours", y="cells", hue="cell_type", stack="percent")
    .endlabels()
    .colors(["#332288", "#117733", "#882255", "#999933"])
    .labels(x="Hours after stimulation", y="Share of leukocytes (%)")
)

Forecast fan chart

Nested 50%, 80% and 95% intervals from 500 simulated trajectories of weekly hospital admissions. Darker bands are more likely.

Fan chart with nested shaded intervals around a median forecast.

Show code

import numpy as np
import pandas as pd
import SecretPlots as sp

sp.use(preset="nature")
rng = np.random.default_rng(2024)

weeks = np.arange(1, 21)
growth = rng.normal(0.06, 0.035, (500, 1))
paths = 120 * np.exp(np.cumsum(growth + rng.normal(0, 0.05, (500, 20)), axis=1))
simulations = pd.DataFrame({
    "week": np.tile(weeks, 500),
    "admissions": paths.ravel(),
})

(
    sp.line(simulations, x="week", y="admissions", error="pi", estimator="median")
    .errorbars(level=(0.5, 0.8, 0.95))
    .colors("#AA3377")
    .labels(x="Week", y="Weekly admissions")
)

Want more?

Each plot type has a guide with every option illustrated: bar · scatter · line · area · histogram · heatmap · box · violin & raincloud. To prepare a figure for a specific journal, read journal-ready figures.