A forest plot displays effect sizes with confidence intervals from multiple studies in a meta-analysis. Each study is represented as a point estimate with horizontal whiskers showing the confidence interval, and a diamond at the bottom shows the pooled estimate. The plot includes a vertical reference line at the null effect (typically 0 or 1), making it easy to assess statistical significance and heterogeneity across studies.

""" anyplot.ai
forest-basic: Meta-Analysis Forest Plot
Library: letsplot 4.9.0 | Python 3.13.13
Quality: 86/100 | Updated: 2026-05-11
"""
import os
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"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
OKABE_ITO_1 = "#009E73"
# Data: Meta-analysis of clinical trials comparing treatment vs control
# Effect sizes are log odds ratios (log OR) - null effect at 0
studies = [
{"study": "Smith 2018", "effect_size": 0.35, "ci_lower": 0.05, "ci_upper": 0.65, "weight": 12.5},
{"study": "Johnson 2019", "effect_size": -0.12, "ci_lower": -0.45, "ci_upper": 0.21, "weight": 10.2},
{"study": "Williams 2019", "effect_size": 0.48, "ci_lower": 0.18, "ci_upper": 0.78, "weight": 11.8},
{"study": "Brown 2020", "effect_size": 0.22, "ci_lower": -0.15, "ci_upper": 0.59, "weight": 9.5},
{"study": "Davis 2020", "effect_size": 0.55, "ci_lower": 0.20, "ci_upper": 0.90, "weight": 8.7},
{"study": "Miller 2021", "effect_size": 0.15, "ci_lower": -0.18, "ci_upper": 0.48, "weight": 11.0},
{"study": "Wilson 2021", "effect_size": 0.42, "ci_lower": 0.12, "ci_upper": 0.72, "weight": 12.0},
{"study": "Moore 2022", "effect_size": 0.28, "ci_lower": -0.08, "ci_upper": 0.64, "weight": 9.8},
{"study": "Taylor 2022", "effect_size": 0.65, "ci_lower": 0.28, "ci_upper": 1.02, "weight": 7.5},
{"study": "Anderson 2023", "effect_size": 0.18, "ci_lower": -0.12, "ci_upper": 0.48, "weight": 12.8},
]
df = pd.DataFrame(studies)
# Calculate pooled estimate (weighted average)
total_weight = df["weight"].sum()
pooled_effect = (df["effect_size"] * df["weight"]).sum() / total_weight
pooled_se = 0.08 # Simplified SE for visualization
pooled_ci_lower = pooled_effect - 1.96 * pooled_se
pooled_ci_upper = pooled_effect + 1.96 * pooled_se
# Order studies by effect size and assign y positions
df = df.sort_values("effect_size", ascending=True).reset_index(drop=True)
df["y_pos"] = range(len(df), 0, -1)
# Scale weights for marker sizes (proportional to study weight)
df["marker_size"] = df["weight"] / df["weight"].max() * 8 + 2
# Create the forest plot
plot = (
ggplot()
# Vertical reference line at null effect (0 for log OR)
+ geom_vline(xintercept=0, color=INK_SOFT, size=1, linetype="dashed")
# Confidence interval lines (whiskers)
+ geom_segment(aes(x="ci_lower", xend="ci_upper", y="y_pos", yend="y_pos"), data=df, color=OKABE_ITO_1, size=1.5)
# Point estimates (squares proportional to weight)
+ geom_point(
aes(x="effect_size", y="y_pos", size="marker_size"),
data=df,
color=OKABE_ITO_1,
shape=15, # Square marker
)
# Study labels on y-axis
+ scale_y_continuous(breaks=df["y_pos"].tolist(), labels=df["study"].tolist())
# Diamond for pooled estimate
+ geom_polygon(
aes(x="x", y="y"),
data=pd.DataFrame(
{"x": [pooled_ci_lower, pooled_effect, pooled_ci_upper, pooled_effect], "y": [-0.5, -1.0, -0.5, 0.0]}
),
fill="#FFD43B",
color=OKABE_ITO_1,
size=1,
)
# Labels and title
+ labs(x="Log Odds Ratio (95% CI)", y="", title="forest-basic · letsplot · pyplots.ai")
# Theme and sizing
+ theme_minimal()
+ theme(
plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
panel_background=element_rect(fill=PAGE_BG),
plot_title=element_text(size=24, face="bold", color=INK),
axis_title_x=element_text(size=20, color=INK),
axis_text_x=element_text(size=16, color=INK_SOFT),
axis_text_y=element_text(size=16, color=INK_SOFT),
axis_line=element_line(color=INK_SOFT),
legend_position="none",
panel_grid_major_y=element_blank(),
panel_grid_minor=element_blank(),
)
+ scale_size_identity()
+ ggsize(1600, 900)
)
# Add text annotation for pooled estimate using geom_text
pooled_label_df = pd.DataFrame(
{
"x": [pooled_effect],
"y": [-1.8],
"label": [f"Pooled: {pooled_effect:.2f} [{pooled_ci_lower:.2f}, {pooled_ci_upper:.2f}]"],
}
)
plot = plot + geom_text(aes(x="x", y="y", label="label"), data=pooled_label_df, size=14, color=INK_SOFT)
# Save as PNG (scale 3x for 4800 × 2700 px)
ggsave(plot, f"plot-{THEME}.png", scale=3, path=".")
# Save as HTML for interactivity
ggsave(plot, f"plot-{THEME}.html", path=".")
Part of Meta-Analysis Forest Plot on anyplot.ai.