Meta-Analysis Funnel Plot for Publication Bias — Plotly

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 Plotly

Python source (Plotly)

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

import os

import numpy as np
import plotly.graph_objects as go


# Theme tokens — Imprint palette + adaptive chrome
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 = "rgba(26,26,23,0.15)" if THEME == "light" else "rgba(240,239,232,0.15)"

# Imprint palette positions used
BRAND = "#009E73"  # position 1 — inside-funnel studies
BLUE = "#4467A3"  # position 3 — pooled effect line
RED = "#AE3030"  # position 5 — semantic anchor for outliers (outside 95% CI)

# Funnel shading derived from BLUE
FUNNEL_FILL_95 = "rgba(68,103,163,0.10)" if THEME == "light" else "rgba(68,103,163,0.16)"
FUNNEL_LINE_95 = "rgba(68,103,163,0.45)" if THEME == "light" else "rgba(68,103,163,0.65)"
FUNNEL_FILL_99 = "rgba(68,103,163,0.05)" if THEME == "light" else "rgba(68,103,163,0.08)"
FUNNEL_LINE_99 = "rgba(68,103,163,0.18)" if THEME == "light" else "rgba(68,103,163,0.30)"

# Data: 15 RCTs comparing drug vs placebo — log odds ratios and standard errors
np.random.seed(42)

studies = [
    "Adams et al. 2016",
    "Baker et al. 2017",
    "Chen et al. 2017",
    "Davis & Park 2018",
    "Evans et al. 2018",
    "Fischer 2019",
    "Gupta et al. 2019",
    "Harris et al. 2020",
    "Ibrahim et al. 2020",
    "Jensen & Liu 2021",
    "Kim et al. 2021",
    "Lambert et al. 2022",
    "Morales et al. 2022",
    "Nielsen 2023",
    "Olsen et al. 2023",
]

log_or = np.array(
    [-0.52, -0.38, -0.71, -0.15, -0.45, -0.63, -0.29, -0.55, -0.42, -0.33, -0.80, -0.48, -0.36, -0.61, -0.10]
)
std_error = np.array([0.18, 0.25, 0.12, 0.30, 0.20, 0.15, 0.28, 0.17, 0.22, 0.26, 0.11, 0.19, 0.24, 0.14, 0.35])

# Inverse-variance weights and pooled effect
weights = 1.0 / std_error**2
pooled_effect = np.sum(weights * log_or) / np.sum(weights)
pct_weights = 100 * weights / weights.sum()

# Marker sizes proportional to study weight
w_norm = weights / weights.max()
marker_sizes = 14 + w_norm * 20  # 14–34 px range

# Classify studies inside vs outside the 95% funnel
outside_funnel = np.abs(log_or - pooled_effect) > 1.96 * std_error
inside_funnel = ~outside_funnel

# Funnel boundary lines
se_max = max(std_error) * 1.1
se_range = np.linspace(0, se_max, 200)
upper_95 = pooled_effect + 1.96 * se_range
lower_95 = pooled_effect - 1.96 * se_range
upper_99 = pooled_effect + 2.576 * se_range
lower_99 = pooled_effect - 2.576 * se_range

# Build figure
fig = go.Figure()

# 99% CI shaded region
fig.add_trace(
    go.Scatter(
        x=np.concatenate([lower_99, upper_99[::-1]]),
        y=np.concatenate([se_range, se_range[::-1]]),
        fill="toself",
        fillcolor=FUNNEL_FILL_99,
        line={"color": FUNNEL_LINE_99, "width": 1, "dash": "dot"},
        showlegend=True,
        name="99% CI region",
        hoverinfo="skip",
    )
)

# 95% CI shaded region
fig.add_trace(
    go.Scatter(
        x=np.concatenate([lower_95, upper_95[::-1]]),
        y=np.concatenate([se_range, se_range[::-1]]),
        fill="toself",
        fillcolor=FUNNEL_FILL_95,
        line={"color": FUNNEL_LINE_95, "width": 1.5},
        showlegend=True,
        name="95% CI region",
        hoverinfo="skip",
    )
)

# Null effect dashed reference line
fig.add_trace(
    go.Scatter(
        x=[0, 0],
        y=[0, se_max],
        mode="lines",
        line={"color": INK_SOFT, "width": 1.5, "dash": "dash"},
        showlegend=False,
        hoverinfo="skip",
    )
)

# Pooled effect line
fig.add_trace(
    go.Scatter(
        x=[pooled_effect, pooled_effect],
        y=[0, se_max],
        mode="lines",
        line={"color": BLUE, "width": 2.5},
        showlegend=True,
        name=f"Pooled OR {np.exp(pooled_effect):.2f} (log {pooled_effect:.2f})",
        hoverinfo="skip",
    )
)

# Studies inside 95% CI funnel
if inside_funnel.any():
    hover_inside = [
        f"<b>{s}</b><br>Log OR: {e:.2f}  (OR = {np.exp(e):.2f})<br>SE: {se:.3f}<br>Weight: {w:.1f}%"
        for s, e, se, w in zip(
            np.array(studies)[inside_funnel],
            log_or[inside_funnel],
            std_error[inside_funnel],
            pct_weights[inside_funnel],
            strict=False,
        )
    ]
    fig.add_trace(
        go.Scatter(
            x=log_or[inside_funnel],
            y=std_error[inside_funnel],
            mode="markers",
            marker={
                "size": marker_sizes[inside_funnel],
                "symbol": "circle",
                "color": BRAND,
                "line": {"color": "#006B4E", "width": 1.5},
                "opacity": 0.88,
            },
            text=hover_inside,
            hovertemplate="%{text}<extra></extra>",
            hoverlabel={"bgcolor": ELEVATED_BG, "bordercolor": BRAND, "font": {"size": 13, "color": INK}},
            name="Within 95% CI",
            showlegend=True,
        )
    )

# Studies outside 95% CI funnel (potential outliers)
if outside_funnel.any():
    hover_outside = [
        f"<b>{s}</b><br>Log OR: {e:.2f}  (OR = {np.exp(e):.2f})<br>SE: {se:.3f}<br>Weight: {w:.1f}%<br>⚠ Outside 95% CI"
        for s, e, se, w in zip(
            np.array(studies)[outside_funnel],
            log_or[outside_funnel],
            std_error[outside_funnel],
            pct_weights[outside_funnel],
            strict=False,
        )
    ]
    fig.add_trace(
        go.Scatter(
            x=log_or[outside_funnel],
            y=std_error[outside_funnel],
            mode="markers",
            marker={
                "size": marker_sizes[outside_funnel],
                "symbol": "diamond",
                "color": RED,
                "line": {"color": "#7A1F1F", "width": 1.5},
                "opacity": 0.88,
            },
            text=hover_outside,
            hovertemplate="%{text}<extra></extra>",
            hoverlabel={"bgcolor": ELEVATED_BG, "bordercolor": RED, "font": {"size": 13, "color": INK}},
            name="Outside 95% CI",
            showlegend=True,
        )
    )

# Layout
title_text = "funnel-meta-analysis · python · plotly · anyplot.ai"
n = len(title_text)
title_size = round(16 * 67 / n) if n > 67 else 16

fig.update_layout(
    autosize=False,
    paper_bgcolor=PAGE_BG,
    plot_bgcolor=PAGE_BG,
    font={"color": INK, "family": "Arial, sans-serif"},
    title={
        "text": title_text,
        "font": {"size": title_size, "color": INK},
        "x": 0.5,
        "xanchor": "center",
        "y": 0.98,
        "yanchor": "top",
    },
    xaxis={
        "title": {"text": "Log Odds Ratio", "font": {"size": 12, "color": INK}, "standoff": 12},
        "tickfont": {"size": 10, "color": INK_SOFT},
        "gridcolor": GRID,
        "linecolor": INK_SOFT,
        "linewidth": 1,
        "showline": True,
        "zeroline": False,
        "range": [-1.15, 0.55],
    },
    yaxis={
        "title": {"text": "Standard Error", "font": {"size": 12, "color": INK}, "standoff": 10},
        "tickfont": {"size": 10, "color": INK_SOFT},
        "gridcolor": GRID,
        "linecolor": INK_SOFT,
        "linewidth": 1,
        "showline": True,
        "zeroline": False,
        "autorange": "reversed",
        "rangemode": "tozero",
    },
    legend={
        "bgcolor": ELEVATED_BG,
        "bordercolor": INK_SOFT,
        "borderwidth": 1,
        "font": {"size": 10, "color": INK_SOFT},
        "x": 0.02,
        "y": 0.98,
        "xanchor": "left",
        "yanchor": "top",
    },
    hovermode="closest",
    margin={"l": 80, "r": 40, "t": 80, "b": 60},
)

# Direction annotations
fig.add_annotation(
    x=0.04,
    xref="paper",
    y=se_max * 0.96,
    text="← Favors Treatment",
    showarrow=False,
    font={"size": 11, "color": BRAND},
    xanchor="left",
)
fig.add_annotation(
    x=0.96,
    xref="paper",
    y=se_max * 0.96,
    text="Favors Control →",
    showarrow=False,
    font={"size": 11, "color": INK_MUTED},
    xanchor="right",
)

# Pooled and null labels near top of chart (where SE ≈ 0)
fig.add_annotation(
    x=pooled_effect + 0.03,
    y=0.005,
    text=f"Pooled ({pooled_effect:.2f})",
    showarrow=False,
    font={"size": 10, "color": BLUE},
    xanchor="left",
    yanchor="top",
)
fig.add_annotation(
    x=0.03,
    y=0.005,
    text="Null (0)",
    showarrow=False,
    font={"size": 10, "color": INK_MUTED},
    xanchor="left",
    yanchor="top",
)

# CI boundary labels — solid background for readability against funnel fill
ci_label_se = se_max * 0.58
fig.add_annotation(
    x=pooled_effect + 1.96 * ci_label_se + 0.03,
    y=ci_label_se,
    text="95% CI",
    showarrow=False,
    font={"size": 10, "color": INK_SOFT},
    bgcolor=PAGE_BG,
    bordercolor="rgba(0,0,0,0)",
    xanchor="left",
)
fig.add_annotation(
    x=pooled_effect + 2.576 * ci_label_se + 0.03,
    y=ci_label_se,
    text="99% CI",
    showarrow=False,
    font={"size": 10, "color": INK_MUTED},
    bgcolor=PAGE_BG,
    bordercolor="rgba(0,0,0,0)",
    xanchor="left",
)

# Subtitle
fig.add_annotation(
    x=0.5,
    xref="paper",
    y=1.0,
    yref="paper",
    text="Asymmetry at low precision (wide SE) suggests possible publication bias  •  Red ◆ outside 95% CI, green ● inside",
    showarrow=False,
    font={"size": 10, "color": INK_MUTED},
    xanchor="center",
    yanchor="bottom",
)

# Save — 3200×1800 landscape (800×450 @ scale=4)
fig.write_image(f"plot-{THEME}.png", width=800, height=450, scale=4)
fig.write_html(f"plot-{THEME}.html", include_plotlyjs="cdn")

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

Other implementations