Meta-Analysis Funnel Plot for Publication Bias — plotnine

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 plotnine

Python source (plotnine)

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

import os
import sys

import numpy as np
import pandas as pd


# Work around naming conflict with plotnine.py script and plotnine package
script_dir = os.path.dirname(os.path.abspath(__file__))
if script_dir in sys.path:
    sys.path.remove(script_dir)
if "" in sys.path:
    sys.path.remove("")
if "." in sys.path:
    sys.path.remove(".")

from plotnine import (
    aes,
    annotate,
    element_blank,
    element_line,
    element_rect,
    element_text,
    geom_line,
    geom_point,
    geom_polygon,
    geom_vline,
    ggplot,
    guide_legend,
    guides,
    labs,
    scale_color_manual,
    scale_shape_manual,
    scale_size_continuous,
    scale_x_continuous,
    scale_y_reverse,
    theme,
    theme_minimal,
)


# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
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 — brand green for inside funnel, semantic red for outside/bias
BRAND = "#009E73"
OUTLIER_COLOR = "#AE3030"  # matte red — semantic anchor for bad/outlier/bias

# Data — 15 RCTs comparing drug vs placebo (log odds ratios)
np.random.seed(42)
n_studies = 15
true_effect = 0.3

std_errors = np.concatenate(
    [np.random.uniform(0.05, 0.15, 5), np.random.uniform(0.15, 0.30, 5), np.random.uniform(0.30, 0.50, 5)]
)
effect_sizes = true_effect + np.random.normal(0, std_errors)

# Add publication bias: shift imprecise (high-SE) studies toward positive
bias_mask = std_errors > 0.35
effect_sizes[bias_mask] += np.random.uniform(0.04, 0.12, bias_mask.sum())

# Clip to keep x-axis balanced and avoid extreme outliers
effect_sizes = np.clip(effect_sizes, -0.55, 0.78)

summary_effect = float(np.average(effect_sizes, weights=1 / std_errors**2))

# Inverse-variance weight for point sizing
weights = 1 / std_errors**2
weight_normalized = weights / weights.max()

# Classify each study by funnel region
lower_ci = summary_effect - 1.96 * std_errors
upper_ci = summary_effect + 1.96 * std_errors
outside_funnel = (effect_sizes < lower_ci) | (effect_sizes > upper_ci)
region = np.where(outside_funnel, "Outside funnel", "Inside funnel")

studies_df = pd.DataFrame(
    {
        "effect_size": effect_sizes,
        "std_error": std_errors,
        "weight": weight_normalized,
        "region": pd.Categorical(region, categories=["Inside funnel", "Outside funnel"]),
    }
)

# Funnel boundary lines (pseudo 95% CI around pooled effect)
se_max = 0.55
se_range = np.linspace(0, se_max, 200)
funnel_lines = pd.DataFrame(
    {
        "effect": np.concatenate([summary_effect - 1.96 * se_range, summary_effect + 1.96 * se_range]),
        "se": np.concatenate([se_range, se_range]),
        "side": ["lower"] * len(se_range) + ["upper"] * len(se_range),
    }
)

# Funnel polygon for shaded interior region
funnel_poly = pd.DataFrame(
    {"x": [summary_effect, summary_effect - 1.96 * se_max, summary_effect + 1.96 * se_max], "y": [0.0, se_max, se_max]}
)

# Title
title = "funnel-meta-analysis · python · plotnine · anyplot.ai"

# Plot
plot = (
    ggplot()
    + geom_polygon(funnel_poly, aes(x="x", y="y"), fill=BRAND, alpha=0.08)
    + geom_line(funnel_lines, aes(x="effect", y="se", group="side"), color=INK_SOFT, linetype="dashed", size=0.7)
    + geom_vline(xintercept=summary_effect, color=BRAND, size=1.2)
    + geom_vline(xintercept=0, color=INK_MUTED, linetype="dotted", size=0.7)
    + geom_point(
        studies_df,
        aes(x="effect_size", y="std_error", size="weight", color="region", shape="region"),
        alpha=0.85,
        stroke=0.3,
    )
    + scale_size_continuous(range=(2.5, 8), guide=None)
    + scale_color_manual(values={"Inside funnel": BRAND, "Outside funnel": OUTLIER_COLOR}, name="")
    + scale_shape_manual(values={"Inside funnel": "o", "Outside funnel": "^"}, name="")
    + guides(color=guide_legend(override_aes={"shape": ["o", "^"]}), shape=False)
    + annotate(
        "text",
        x=summary_effect + 0.45,
        y=0.08,
        label="Asymmetry suggests\npublication bias",
        size=11,
        color=OUTLIER_COLOR,
        fontstyle="italic",
        ha="center",
    )
    + annotate(
        "text",
        x=summary_effect + 0.02,
        y=0.01,
        label=f"Pooled effect = {summary_effect:.2f}",
        size=10,
        color=BRAND,
        ha="left",
        va="top",
    )
    + annotate("text", x=-0.02, y=0.01, label="Null", size=10, color=INK_MUTED, ha="right", va="top")
    + scale_y_reverse(limits=(0.60, -0.02))
    + scale_x_continuous(breaks=np.arange(-0.6, 1.2, 0.2).round(1).tolist())
    + labs(x="Log Odds Ratio", y="Standard Error", color="", title=title)
    + theme_minimal()
    + theme(
        figure_size=(8, 4.5),
        plot_title=element_text(size=12, weight="bold", color=INK),
        axis_title=element_text(size=10, color=INK),
        axis_text=element_text(size=8, color=INK_SOFT),
        panel_grid_major_y=element_line(color=INK, size=0.3, alpha=0.12),
        panel_grid_major_x=element_blank(),
        panel_grid_minor=element_blank(),
        panel_background=element_rect(fill=PAGE_BG, color="none"),
        plot_background=element_rect(fill=PAGE_BG, color="none"),
        legend_position=(0.12, 0.12),
        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),
        legend_text=element_text(size=8, color=INK_SOFT),
        legend_title=element_blank(),
        axis_line=element_line(color=INK_SOFT, size=0.5),
    )
)

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

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

Other implementations