Split Violin Plot — Pygal

A split violin plot displaying two distributions side-by-side within each violin, with each half representing a different group. Unlike standard violin plots that mirror the same distribution, split violins use the left and right halves to compare two conditions (such as before/after, male/female, or control/treatment) at each category level. This enables direct visual comparison of distribution shapes between paired groups.

Split Violin Plot rendered with Pygal

Python source (Pygal)

""" anyplot.ai
violin-split: Split Violin Plot
Library: pygal 3.1.0 | Python 3.13.13
Quality: 92/100 | Updated: 2026-05-08
"""

import os

import numpy as np
import pygal
from pygal.style import Style


# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Okabe-Ito palette: position 1 (brand green) and position 2 (vermillion)
COLOR_1 = "#009E73"  # Before
COLOR_2 = "#C475FD"  # After

# Data - Patient recovery scores before/after treatment across clinics
np.random.seed(42)
categories = ["Clinic A", "Clinic B", "Clinic C", "Clinic D"]
split_groups = ["Before", "After"]

# Generate realistic before/after data with different improvements per clinic
data = {}
for cat in categories:
    data[cat] = {}
    if cat == "Clinic A":
        data[cat]["Before"] = np.random.normal(45, 12, 80)
        data[cat]["After"] = np.random.normal(72, 10, 80)
    elif cat == "Clinic B":
        data[cat]["Before"] = np.random.normal(50, 15, 80)
        data[cat]["After"] = np.random.normal(68, 12, 80)
    elif cat == "Clinic C":
        data[cat]["Before"] = np.random.normal(42, 10, 80)
        data[cat]["After"] = np.random.normal(78, 8, 80)
    else:  # Clinic D
        data[cat]["Before"] = np.random.normal(55, 18, 80)
        data[cat]["After"] = np.random.normal(65, 14, 80)

# Clip to realistic 0-100 range
for cat in categories:
    for group in split_groups:
        data[cat][group] = np.clip(data[cat][group], 10, 95)

# Custom style for 4800x2700 px canvas
custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_MUTED,
    guide_stroke_color=INK_MUTED,
    colors=(COLOR_1, COLOR_2),
    title_font_size=28,
    label_font_size=22,
    major_label_font_size=18,
    legend_font_size=16,
    value_font_size=14,
    opacity=0.7,
    opacity_hover=0.9,
)

# Create XY chart for split violin plot
chart = pygal.XY(
    width=4800,
    height=2700,
    style=custom_style,
    title="violin-split · pygal · anyplot.ai",
    x_title="Clinic",
    y_title="Recovery Score (0-100)",
    show_legend=True,
    legend_at_bottom=True,
    legend_at_bottom_columns=2,
    stroke=True,
    fill=True,
    dots_size=0,
    show_x_guides=False,
    show_y_guides=True,
    range=(0, 100),
    xrange=(0, 5.5),
    margin=60,
)

# Parameters for violin shapes
violin_width = 0.38
n_points = 80
marker_width = 0.04

# Pre-compute all violin shapes and markers
before_violins = []
after_violins = []
before_markers = []
after_markers = []

for i, category in enumerate(categories):
    center_x = i + 1.25

    for group in split_groups:
        values = data[category][group]

        # Create range of y values for density
        y_min, y_max = values.min(), values.max()
        padding = (y_max - y_min) * 0.15
        y_range = np.linspace(y_min - padding, y_max + padding, n_points)

        # Compute Gaussian KDE using Silverman's rule (inlined)
        n = len(values)
        std = np.std(values)
        iqr = np.percentile(values, 75) - np.percentile(values, 25)
        bandwidth = 0.9 * min(std, iqr / 1.34) * n ** (-0.2)

        density = np.zeros_like(y_range)
        for v in values:
            density += np.exp(-0.5 * ((y_range - v) / bandwidth) ** 2)
        density /= n * bandwidth * np.sqrt(2 * np.pi)

        # Normalize density to desired width
        density = density / density.max() * violin_width

        # Compute quartile statistics
        median = float(np.median(values))
        q1 = float(np.percentile(values, 25))
        q3 = float(np.percentile(values, 75))

        # Create half-violin shape (split violin - each group on one side)
        if group == "Before":
            # Left half - density extends to the left
            half_points = [(center_x - d, y) for y, d in zip(y_range, density, strict=True)]
            half_points = [(center_x, y_range[0])] + half_points + [(center_x, y_range[-1]), (center_x, y_range[0])]
            before_violins.append(half_points)
            before_markers.append((center_x, median, q1, q3, -0.08))
        else:
            # Right half - density extends to the right
            half_points = [(center_x + d, y) for y, d in zip(y_range, density, strict=True)]
            half_points = [(center_x, y_range[0])] + half_points + [(center_x, y_range[-1]), (center_x, y_range[0])]
            after_violins.append(half_points)
            after_markers.append((center_x, median, q1, q3, 0.08))

# Add all "Before" violins first (green, with legend entry for first one only)
for i, violin in enumerate(before_violins):
    label = "Before" if i == 0 else None
    chart.add(label, violin, show_dots=False)

# Add all "After" violins (orange, with legend entry for first one only)
for i, violin in enumerate(after_violins):
    label = "After" if i == 0 else None
    chart.add(label, violin, show_dots=False)

# Add quartile markers for "Before" group (no legend entries)
for center_x, median, q1, q3, offset in before_markers:
    # IQR line (thin vertical line)
    iqr_line = [(center_x + offset, q1), (center_x + offset, q3)]
    chart.add(None, iqr_line, stroke=True, fill=False, show_dots=False, stroke_style={"width": 8})
    # Median marker (small horizontal line)
    median_line = [(center_x + offset - marker_width, median), (center_x + offset + marker_width, median)]
    chart.add(None, median_line, stroke=True, fill=False, show_dots=False, stroke_style={"width": 12})

# Add quartile markers for "After" group (no legend entries)
for center_x, median, q1, q3, offset in after_markers:
    # IQR line
    iqr_line = [(center_x + offset, q1), (center_x + offset, q3)]
    chart.add(None, iqr_line, stroke=True, fill=False, show_dots=False, stroke_style={"width": 8})
    # Median marker
    median_line = [(center_x + offset - marker_width, median), (center_x + offset + marker_width, median)]
    chart.add(None, median_line, stroke=True, fill=False, show_dots=False, stroke_style={"width": 12})

# X-axis labels for categories
chart.x_labels = [
    {"value": 0, "label": ""},
    {"value": 1.25, "label": "Clinic A"},
    {"value": 2.25, "label": "Clinic B"},
    {"value": 3.25, "label": "Clinic C"},
    {"value": 4.25, "label": "Clinic D"},
    {"value": 5.5, "label": ""},
]

# Save outputs
chart.render_to_file(f"plot-{THEME}.html")
chart.render_to_png(f"plot-{THEME}.png")

Part of Split Violin Plot on anyplot.ai.

Other implementations