Meta-Analysis Funnel Plot for Publication Bias — lets-plot

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 lets-plot

Python source (lets-plot)

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

import os

import numpy as np
import pandas as pd
from lets_plot import *


LetsPlot.setup_html()

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"
GRID_COLOR = "#D9D7D0" if THEME == "light" else "#3A3A36"

# Imprint palette — first series always #009E73
BRAND = "#009E73"  # Inside funnel (expected, within confidence limits)
OUTLIER = "#AE3030"  # Outside funnel — semantic anchor: potential bias / outlier

# Data: Meta-analysis of 15 RCTs comparing drug vs placebo
# Effect sizes are log odds ratios; null effect at 0
studies = [
    {"study": "Adams 2015", "effect_size": 0.42, "std_error": 0.18, "n": 120},
    {"study": "Baker 2016", "effect_size": 0.28, "std_error": 0.22, "n": 85},
    {"study": "Chen 2016", "effect_size": 0.65, "std_error": 0.30, "n": 48},
    {"study": "Davis 2017", "effect_size": -0.08, "std_error": 0.25, "n": 64},
    {"study": "Evans 2017", "effect_size": 0.52, "std_error": 0.12, "n": 280},
    {"study": "Foster 2018", "effect_size": 0.10, "std_error": 0.35, "n": 34},
    {"study": "Garcia 2018", "effect_size": 0.38, "std_error": 0.15, "n": 180},
    {"study": "Hughes 2019", "effect_size": 0.55, "std_error": 0.28, "n": 52},
    {"study": "Ito 2019", "effect_size": 0.30, "std_error": 0.10, "n": 410},
    {"study": "Jensen 2020", "effect_size": 1.05, "std_error": 0.32, "n": 40},
    {"study": "Klein 2020", "effect_size": -0.15, "std_error": 0.14, "n": 205},
    {"study": "Lee 2021", "effect_size": 0.48, "std_error": 0.20, "n": 100},
    {"study": "Morgan 2021", "effect_size": 0.33, "std_error": 0.16, "n": 160},
    {"study": "Nguyen 2022", "effect_size": 0.88, "std_error": 0.30, "n": 46},
    {"study": "Olsen 2023", "effect_size": -0.05, "std_error": 0.38, "n": 28},
]

df = pd.DataFrame(studies)

# Inverse-variance weights and pooled effect estimate
weights = 1 / df["std_error"] ** 2
pooled_effect = (df["effect_size"] * weights).sum() / weights.sum()
df["iw"] = weights

# Classify studies: inside or outside the 95% funnel boundary
df["position"] = np.where(
    (df["effect_size"] >= pooled_effect - 1.96 * df["std_error"])
    & (df["effect_size"] <= pooled_effect + 1.96 * df["std_error"]),
    "Inside funnel",
    "Outside funnel",
)

# Pseudo 95% confidence funnel boundary
se_max = df["std_error"].max() + 0.05
se_range = np.linspace(0, se_max, 200)
funnel_upper = pooled_effect + 1.96 * se_range
funnel_lower = pooled_effect - 1.96 * se_range

funnel_df = pd.DataFrame(
    {"x": np.concatenate([funnel_lower, funnel_upper[::-1]]), "y": np.concatenate([se_range, se_range[::-1]])}
)

funnel_lines_df = pd.DataFrame(
    {
        "x": np.concatenate([funnel_lower, funnel_upper]),
        "y": np.concatenate([se_range, se_range]),
        "side": ["lower"] * len(se_range) + ["upper"] * len(se_range),
    }
)

# Pooled OR annotation (single-row data frame for geom_label)
annotation_df = pd.DataFrame(
    {"x": [pooled_effect + 0.04], "y": [0.012], "label": [f"Pooled OR = {np.exp(pooled_effect):.2f}"]}
)

# Top 3 highest-weight inside-funnel studies for labeling
inside_top = df[df["position"] == "Inside funnel"].nlargest(3, "iw")

title = "funnel-meta-analysis · python · letsplot · anyplot.ai"
title_size = round(16 * min(1.0, 67 / len(title)))

plot = (
    ggplot()
    # Funnel confidence region (shaded polygon)
    + geom_polygon(aes(x="x", y="y"), data=funnel_df, fill=BRAND, alpha=0.07)
    # Funnel boundary lines (95% CI dashed)
    + geom_line(
        aes(x="x", y="y", group="side"), data=funnel_lines_df, color=BRAND, size=0.8, linetype="dashed", alpha=0.5
    )
    # Null effect reference line
    + geom_vline(xintercept=0, color=INK_MUTED, size=0.6, linetype="dashed", alpha=0.7)
    # Pooled effect line
    + geom_vline(xintercept=pooled_effect, color=INK, size=1.1, alpha=0.85)
    # Study points — sized by inverse-variance weight, colored by classification
    + geom_point(
        aes(x="effect_size", y="std_error", color="position", size="iw"),
        data=df,
        shape=16,
        alpha=0.88,
        tooltips=layer_tooltips()
        .title("@study")
        .line("Effect (log OR)|@effect_size{.3f}")
        .line("Std. error|@std_error{.3f}")
        .line("Sample size|@n")
        .line("Status|@position"),
    )
    + scale_color_manual(values={"Inside funnel": BRAND, "Outside funnel": OUTLIER}, name="Classification")
    + scale_size(range=[2.0, 7.0], guide="none")
    # Outlier labels with filled background — right-align Jensen 2020 to prevent canvas overflow
    + geom_label(
        aes(x="effect_size", y="std_error", label="study"),
        data=df[df["study"] == "Jensen 2020"],
        color=OUTLIER,
        fill=ELEVATED_BG,
        size=3.2,
        nudge_y=-0.022,
        hjust=1.1,
        fontface="bold",
        show_legend=False,
    )
    + geom_label(
        aes(x="effect_size", y="std_error", label="study"),
        data=df[df["study"] == "Klein 2020"],
        color=OUTLIER,
        fill=ELEVATED_BG,
        size=3.2,
        nudge_y=-0.022,
        hjust=-0.15,
        fontface="bold",
        show_legend=False,
    )
    # Top-weight inside-funnel study labels
    + geom_text(
        aes(x="effect_size", y="std_error", label="study"),
        data=inside_top,
        color=INK_SOFT,
        size=2.8,
        nudge_y=-0.018,
        show_legend=False,
    )
    # Pooled OR annotation box
    + geom_label(
        aes(x="x", y="y", label="label"),
        data=annotation_df,
        color=INK,
        fill=ELEVATED_BG,
        size=3.5,
        fontface="bold",
        hjust=0,
        show_legend=False,
    )
    # Inverted y-axis: lower SE (higher precision) at the top
    + scale_y_reverse()
    + labs(x="Log Odds Ratio", y="Standard Error (precision ↑)", title=title)
    + ggsize(800, 450)
    + theme_minimal()
    + theme(
        plot_title=element_text(size=title_size, face="bold", color=INK),
        axis_title=element_text(size=12, color=INK),
        axis_text=element_text(size=10, color=INK_SOFT),
        panel_grid_major_x=element_blank(),
        panel_grid_major_y=element_line(color=GRID_COLOR, size=0.3),
        panel_grid_minor=element_blank(),
        axis_line=element_line(color=INK_SOFT, size=0.4),
        legend_position=[0.85, 0.82],
        legend_title=element_text(size=10, face="bold", color=INK),
        legend_text=element_text(size=10, color=INK_SOFT),
        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT, size=0.5),
        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
        panel_background=element_rect(fill=PAGE_BG),
    )
)

ggsave(plot, f"plot-{THEME}.png", scale=4, path=".")
ggsave(plot, f"plot-{THEME}.html", path=".")

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

Other implementations