Meta-Analysis Funnel Plot for Publication Bias — Pygal

A funnel plot used in meta-analysis to assess publication bias by plotting individual study effect sizes against their precision (typically standard error). Studies scatter around a summary effect line, with pseudo 95% confidence limits forming an inverted funnel shape. In the absence of bias, studies distribute symmetrically around the summary effect; asymmetry suggests publication bias or systematic heterogeneity. This is a standard tool in systematic reviews and Cochrane-style meta-analyses.

Meta-Analysis Funnel Plot for Publication Bias rendered with Pygal

Python source (Pygal)

""" anyplot.ai
funnel-meta-analysis: Meta-Analysis Funnel Plot for Publication Bias
Library: pygal 3.1.0 | Python 3.13.13
Quality: 88/100 | Updated: 2026-06-10
"""

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_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Imprint palette — first categorical series always #009E73
IMPRINT_GREEN = "#009E73"  # position 1 — high-precision studies
IMPRINT_LAVENDER = "#C475FD"  # position 2 — low-precision studies

# Data: 15 RCTs comparing drug vs placebo (log odds ratios)
np.random.seed(42)
study_names = [
    "Adams 2018",
    "Baker 2019",
    "Chen 2019",
    "Davis 2020",
    "Evans 2020",
    "Foster 2021",
    "Garcia 2021",
    "Harris 2022",
    "Ibrahim 2022",
    "Jones 2022",
    "Kim 2023",
    "Lee 2023",
    "Martinez 2023",
    "Nelson 2024",
    "O'Brien 2024",
]

# Effect sizes (log odds ratios) and standard errors
# High-precision studies cluster near pooled effect; low-precision studies
# show rightward asymmetry (missing negative small studies = publication bias)
effect_sizes = np.array(
    [-0.50, -0.42, -0.68, -0.46, -0.25, -0.55, -0.48, -0.44, -0.38, -0.20, -0.72, -0.43, -0.08, -0.51, -0.58]
)
std_errors = np.array([0.08, 0.11, 0.17, 0.09, 0.16, 0.21, 0.13, 0.07, 0.22, 0.18, 0.26, 0.10, 0.23, 0.12, 0.24])

pooled_effect = -0.47

high_precision_mask = std_errors < 0.15
low_precision_mask = ~high_precision_mask

# Style — theme-adaptive chrome + Imprint palette
# Structural reference lines (CI, pooled effect, null) use INK tokens;
# data series (study groups) use Imprint categorical positions 1 and 3.
custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_MUTED,
    colors=(
        INK_MUTED,  # 0: CI left boundary (structural line, not a data category)
        INK_MUTED,  # 1: CI right boundary (hidden from legend)
        INK,  # 2: Pooled effect line (structural reference)
        INK_SOFT,  # 3: Null effect line (structural reference)
        IMPRINT_GREEN,  # 4: High-precision studies — Imprint position 1
        IMPRINT_LAVENDER,  # 5: Low-precision studies — Imprint position 2
    ),
    title_font_size=66,
    label_font_size=56,
    major_label_font_size=44,
    legend_font_size=44,
    value_font_size=36,
    stroke_width=2.5,
    font_family="Helvetica, Arial, sans-serif",
    opacity=".9",
    opacity_hover="1",
)

# Chart — 3200×1800 landscape canvas
chart = pygal.XY(
    width=3200,
    height=1800,
    explicit_size=True,
    title="funnel-meta-analysis · python · pygal · anyplot.ai",
    x_title="Log Odds Ratio (Effect Size)",
    y_title="Standard Error (precision ↑)",
    style=custom_style,
    show_legend=True,
    legend_at_bottom=True,
    legend_at_bottom_columns=2,
    legend_box_size=30,
    dots_size=14,
    stroke=False,
    show_y_guides=True,
    show_x_guides=False,
    margin=120,
    inverse_y_axis=True,
    truncate_legend=-1,
    x_value_formatter=lambda x: f"{x:.2f}",
    y_value_formatter=lambda y: f"{y:.2f}",
    y_labels=[0.00, 0.05, 0.10, 0.15, 0.20, 0.25, 0.30],
    print_values=False,
    print_zeroes=False,
    range=(0, 0.30),
    xrange=(-1.05, 0.15),
    spacing=30,
    tooltip_border_radius=8,
)

# Funnel boundaries — pseudo 95% CI diagonal lines
se_values = np.linspace(0, 0.30, 60)
funnel_left = [(float(pooled_effect - 1.96 * se), float(se)) for se in se_values]
chart.add(
    "95% Pseudo CI",
    funnel_left,
    stroke=True,
    show_dots=False,
    stroke_style={"width": 4, "dasharray": "14, 6"},
    formatter=lambda x: "",
)

funnel_right = [(float(pooled_effect + 1.96 * se), float(se)) for se in se_values]
chart.add(
    None,
    funnel_right,
    stroke=True,
    show_dots=False,
    stroke_style={"width": 4, "dasharray": "14, 6"},
    formatter=lambda x: "",
)

# Vertical line at pooled effect (solid, prominent)
chart.add(
    f"Pooled Effect (LOR = {pooled_effect:.2f})",
    [(float(pooled_effect), 0.0), (float(pooled_effect), 0.30)],
    stroke=True,
    show_dots=False,
    stroke_style={"width": 6},
    formatter=lambda x: "",
)

# Vertical dashed line at null effect
chart.add(
    "Null Effect (LOR = 0)",
    [(0.0, 0.0), (0.0, 0.30)],
    stroke=True,
    show_dots=False,
    stroke_style={"width": 3, "dasharray": "10, 8"},
    formatter=lambda x: "",
)

# High-precision studies — larger markers, tightly clustered near pooled effect
hp_points = [
    {"value": (float(es), float(se)), "label": f"{name}: LOR={es:.2f}, SE={se:.2f}"}
    for name, es, se in zip(
        np.array(study_names)[high_precision_mask],
        effect_sizes[high_precision_mask],
        std_errors[high_precision_mask],
        strict=True,
    )
]
chart.add("High-precision studies", hp_points, stroke=False, dots_size=24, formatter=lambda x: "")

# Low-precision studies — smaller markers, rightward asymmetry signals publication bias
lp_points = [
    {"value": (float(es), float(se)), "label": f"{name}: LOR={es:.2f}, SE={se:.2f}"}
    for name, es, se in zip(
        np.array(study_names)[low_precision_mask],
        effect_sizes[low_precision_mask],
        std_errors[low_precision_mask],
        strict=True,
    )
]
chart.add("Low-precision studies (bias region)", lp_points, stroke=False, dots_size=18, formatter=lambda x: "")

# Save
chart.render_to_png(f"plot-{THEME}.png")
with open(f"plot-{THEME}.html", "wb") as f:
    f.write(chart.render())

Part of Meta-Analysis Funnel Plot for Publication Bias on anyplot.ai.

Other implementations