Basic Swarm Plot — plotnine

A swarm plot (beeswarm plot) displays individual data points for categorical comparisons, with points spread horizontally to avoid overlap. This reveals the full distribution shape and density while preserving exact values - combining the benefits of strip plots (individual points) and violin plots (density visualization). Ideal when you need to see every observation rather than just summary statistics.

Basic Swarm Plot rendered with plotnine

Python source (plotnine)

""" anyplot.ai
swarm-basic: Basic Swarm Plot
Library: plotnine 0.15.7 | Python 3.13.14
Quality: 89/100 | Updated: 2026-07-26
"""

import sys


sys.path.pop(0)  # prevent this file from shadowing the installed plotnine package

import os

import numpy as np
import pandas as pd
from plotnine import (
    aes,
    element_blank,
    element_line,
    element_rect,
    element_text,
    geom_line,
    geom_point,
    ggplot,
    labs,
    scale_color_manual,
    scale_x_continuous,
    theme,
    theme_minimal,
)


THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
IMPRINT = ["#009E73", "#C475FD", "#4467A3", "#BD8233"]

# Data - Patient biomarker levels across treatment groups
np.random.seed(42)

treatment_groups = ["Placebo", "Low Dose", "Medium Dose", "High Dose"]

distributions = {
    "Placebo": {"mean": 45, "std": 12, "n": 50},
    "Low Dose": {"mean": 55, "std": 10, "n": 45},
    "Medium Dose": {"mean": 68, "std": 8, "n": 55},
    "High Dose": {"mean": 75, "std": 6, "n": 40},
}

data = []
for group, params in distributions.items():
    values = np.random.normal(params["mean"], params["std"], params["n"])
    values = np.clip(values, 20, 100)
    data.extend([(group, value) for value in values])

df = pd.DataFrame(data, columns=["treatment", "biomarker"])
df["treatment"] = pd.Categorical(df["treatment"], categories=treatment_groups, ordered=True)
df["x_num"] = df["treatment"].cat.codes.astype(float)


# Deterministic beeswarm packing: sweep points in ascending value order and
# place each one in the nearest-to-center offset slot (alternating sides)
# whose most recent occupant already cleared a minimum vertical gap — a slot
# only frees up once its last point is far enough below the new one, so
# offsets keep growing in dense stretches instead of every sparse column
# resetting back to center and stacking near-concentrically with its neighbor.
# min_gap is fixed to the shared y-axis scale (not each group's own spread)
# since the marker's on-canvas footprint is the same regardless of group.
def beeswarm_offsets(values, min_gap, spacing=0.09):
    offsets = np.zeros(len(values))
    slot_last_y = {}  # offset slot (int) -> value of the last point placed there
    for idx in np.argsort(values):
        y = values[idx]
        step = 0
        while True:
            for slot in (0,) if step == 0 else (step, -step):
                last_y = slot_last_y.get(slot)
                if last_y is None or y - last_y >= min_gap:
                    offsets[idx] = slot * spacing
                    slot_last_y[slot] = y
                    step = None
                    break
            if step is None:
                break
            step += 1
    return offsets


swarm_min_gap = (df["biomarker"].max() - df["biomarker"].min()) * 0.05
for group in treatment_groups:
    mask = df["treatment"] == group
    df.loc[mask, "x_num"] += beeswarm_offsets(df.loc[mask, "biomarker"].to_numpy(), swarm_min_gap)

medians_df = df.groupby("treatment", observed=True)["biomarker"].median().reset_index()
medians_df["x_num"] = medians_df["treatment"].cat.codes.astype(float)

# Plot
plot = (
    ggplot(df, aes(x="x_num", y="biomarker", color="treatment"))
    + geom_point(size=2.2, alpha=0.75)
    + geom_line(
        medians_df,
        aes(x="x_num", y="biomarker", group=1),
        linetype="dashed",
        color=INK_SOFT,
        size=1.0,
        inherit_aes=False,
    )
    + geom_point(medians_df, aes(x="x_num", y="biomarker"), size=6, shape="D", color=INK, inherit_aes=False)
    + scale_color_manual(values=IMPRINT)
    + scale_x_continuous(breaks=list(range(len(treatment_groups))), labels=treatment_groups)
    + labs(x="Treatment Group", y="Biomarker Level (ng/mL)", title="swarm-basic · plotnine · anyplot.ai")
    + theme_minimal()
    + theme(
        figure_size=(8, 4.5),
        text=element_text(size=7),
        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
        panel_background=element_rect(fill=PAGE_BG),
        panel_border=element_blank(),
        panel_grid_major=element_line(color=INK, size=0.3, alpha=0.08),
        panel_grid_minor=element_line(color=INK, size=0.2, alpha=0.04),
        axis_ticks_major=element_blank(),
        axis_title=element_text(color=INK, size=10),
        axis_text=element_text(color=INK_SOFT, size=8),
        plot_title=element_text(color=INK, size=13),
        legend_position="none",
    )
)

# Save
plot.save(f"plot-{THEME}.png", dpi=400, width=8, height=4.5, units="in")

Part of Basic Swarm Plot on anyplot.ai.

Other implementations