Meta-Analysis Funnel Plot for Publication Bias — Altair

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 Altair

Python source (Altair)

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

import importlib
import os
import sys

import numpy as np
import pandas as pd
from PIL import Image


# Drop script directory from sys.path so `altair` resolves the package, not this file
sys.path[:] = [p for p in sys.path if os.path.abspath(p or ".") != os.path.dirname(os.path.abspath(__file__))]
alt = importlib.import_module("altair")

# Theme setup — Imprint palette 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"

# Imprint categorical palette — semantic assignments
COLOR_WITHIN = "#009E73"  # brand green — studies within funnel (typical)
COLOR_OUTSIDE = "#AE3030"  # matte red — studies outside funnel (potential bias)
COLOR_FUNNEL = "#4467A3"  # blue — funnel confidence structure (reference)

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

studies = [
    "Adams 2016",
    "Baker 2017",
    "Chen 2017",
    "Davis 2018",
    "Evans 2018",
    "Foster 2019",
    "Garcia 2019",
    "Harris 2020",
    "Ibrahim 2020",
    "Jones 2021",
    "Klein 2021",
    "Lopez 2022",
    "Mitchell 2022",
    "Nelson 2023",
    "O'Brien 2023",
]

effect_sizes = np.array(
    [-0.52, -0.31, -0.85, -0.45, -0.08, -0.62, -0.38, -0.55, -0.41, -0.28, -0.78, -0.33, -0.48, -0.25, -1.05]
)

std_errors = np.array([0.18, 0.12, 0.24, 0.15, 0.10, 0.22, 0.14, 0.20, 0.16, 0.11, 0.21, 0.13, 0.17, 0.09, 0.28])

# Summary effect (inverse-variance weighted mean)
weights = 1.0 / std_errors**2
summary_effect = np.sum(weights * effect_sizes) / np.sum(weights)

# Classify studies as inside or outside the funnel (pseudo 95% CI)
lower_bound = summary_effect - 1.96 * std_errors
upper_bound = summary_effect + 1.96 * std_errors
inside_funnel = (effect_sizes >= lower_bound) & (effect_sizes <= upper_bound)

df = pd.DataFrame(
    {
        "study": studies,
        "effect_size": effect_sizes,
        "std_error": std_errors,
        "weight": weights / weights.max(),
        "region": np.where(inside_funnel, "Within funnel", "Outside funnel"),
    }
)

# Funnel confidence limits (pseudo 95% CI)
se_max = max(std_errors) + 0.02
se_range = np.linspace(0, se_max, 100)
funnel_df = pd.DataFrame(
    {"se": se_range, "lower": summary_effect - 1.96 * se_range, "upper": summary_effect + 1.96 * se_range}
)

y_scale = alt.Scale(domain=[se_max, 0])

# Funnel confidence area fill
funnel_area = (
    alt.Chart(funnel_df)
    .transform_fold(["lower", "upper"], as_=["bound", "value"])
    .mark_area(opacity=0.07, color=COLOR_FUNNEL)
    .encode(x=alt.X("value:Q"), y=alt.Y("se:Q", scale=y_scale), detail="bound:N")
)

# Funnel confidence bounds (dashed lines)
funnel_left = (
    alt.Chart(funnel_df)
    .mark_line(color=COLOR_FUNNEL, strokeDash=[6, 3], strokeWidth=1.5, opacity=0.5)
    .encode(x=alt.X("lower:Q"), y=alt.Y("se:Q", scale=y_scale))
)

funnel_right = (
    alt.Chart(funnel_df)
    .mark_line(color=COLOR_FUNNEL, strokeDash=[6, 3], strokeWidth=1.5, opacity=0.5)
    .encode(x=alt.X("upper:Q"), y=alt.Y("se:Q", scale=y_scale))
)

# Summary effect vertical line
summary_line = (
    alt.Chart(pd.DataFrame({"x": [summary_effect]}))
    .mark_rule(color=COLOR_FUNNEL, strokeWidth=2.5, opacity=0.8)
    .encode(x="x:Q")
)

# Null effect reference line at 0
null_line = (
    alt.Chart(pd.DataFrame({"x": [0]}))
    .mark_rule(color=INK_MUTED, strokeDash=[8, 4], strokeWidth=1.5, opacity=0.7)
    .encode(x="x:Q")
)

# Color-coded study points sized by inverse-variance weight
region_color = alt.Color(
    "region:N",
    scale=alt.Scale(domain=["Within funnel", "Outside funnel"], range=[COLOR_WITHIN, COLOR_OUTSIDE]),
    legend=alt.Legend(title=None, orient="bottom-right", labelFontSize=10, symbolSize=100),
)

# Redundant shape encoding for CVD accessibility (circle vs diamond)
region_shape = alt.Shape(
    "region:N", scale=alt.Scale(domain=["Within funnel", "Outside funnel"], range=["circle", "diamond"]), legend=None
)

points = (
    alt.Chart(df)
    .mark_point(filled=True, stroke="white", strokeWidth=1.5, opacity=0.9)
    .encode(
        x=alt.X("effect_size:Q", title="Log Odds Ratio", scale=alt.Scale(domain=[-1.15, 0.35])),
        y=alt.Y("std_error:Q", title="Standard Error", scale=y_scale),
        color=region_color,
        shape=region_shape,
        size=alt.Size("weight:Q", scale=alt.Scale(range=[160, 520]), legend=None),
        tooltip=[
            alt.Tooltip("study:N", title="Study"),
            alt.Tooltip("effect_size:Q", title="Log OR", format=".2f"),
            alt.Tooltip("std_error:Q", title="SE", format=".3f"),
            alt.Tooltip("region:N", title="Region"),
        ],
    )
)

chart = (
    alt.layer(funnel_area, funnel_left, funnel_right, null_line, summary_line, points)
    .properties(
        width=620,
        height=320,
        background=PAGE_BG,
        title=alt.Title(
            "funnel-meta-analysis · python · altair · anyplot.ai",
            fontSize=16,
            anchor="middle",
            color=INK,
            subtitle="Asymmetry suggests publication bias — red points fall outside the 95% pseudo-confidence funnel",
            subtitleFontSize=10,
            subtitleColor=INK_SOFT,
        ),
    )
    .configure_view(fill=PAGE_BG, stroke=INK_SOFT, continuousWidth=620, continuousHeight=320)
    .configure_axis(
        labelFontSize=10,
        titleFontSize=12,
        titleColor=INK,
        labelColor=INK_SOFT,
        domainColor=INK_SOFT,
        tickColor=INK_SOFT,
        gridColor=INK,
        gridOpacity=0.15,
    )
    .configure_axisX(grid=False)
    .configure_axisY(grid=False)
    .configure_legend(
        padding=8, cornerRadius=4, fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, labelFontSize=10
    )
)

chart.save(f"plot-{THEME}.png", scale_factor=4.0)

# Pad-only to exact canvas target (3200 × 1800 landscape)
TW, TH = 3200, 1800
_img = Image.open(f"plot-{THEME}.png").convert("RGB")
_w, _h = _img.size
if _w > TW or _h > TH:
    raise SystemExit(
        f"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. "
        "Shrink chart .properties(width=, height=) values and re-render."
    )
if _w < TW or _h < TH:
    _canvas = Image.new("RGB", (TW, TH), PAGE_BG)
    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))
    _canvas.save(f"plot-{THEME}.png")

chart.save(f"plot-{THEME}.html")

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

Other implementations