Chris Parmer — home

Forest plot

BCG vaccine vs TB meta-analysis (13 trials)

Example from the compendium of canonical charts

Forest plot — BCG vaccine vs TB meta-analysis (13 trials)

Python Code

"""Forest plot — BCG vaccine vs TB meta-analysis (13 trials)."""


import numpy as np
import pandas as pd
import plotly.graph_objects as go

URL = "https://vincentarelbundock.github.io/Rdatasets/csv/HSAUR/BCG.csv"


def generate():
    print("fetching BCG meta-analysis data …")
    df = fetch_csv(URL)
    df.columns = [c.strip() for c in df.columns]
    print(f"  cols: {list(df.columns)}")

    # Expected cols: BCGTB, BCGVacc, NoVaccTB, NoVacc (plus Trial and possibly other metadata)
    tb_v  = "BCGTB"
    n_v   = "BCGVacc"
    tb_nv = "NoVaccTB"
    n_nv  = "NoVacc"

    # Flexible column matching
    for expected, alts in [(tb_v, ["bcgtb","vacc_tb","treated_tb"]),
                           (n_v, ["bcgvacc","vacc_n","n_treated"]),
                           (tb_nv, ["novacctb","control_tb"]),
                           (n_nv, ["novacc","control_n","n_control"])]:
        if expected not in df.columns:
            match = next((c for c in df.columns if c.lower() in alts), None)
            if match:
                df = df.rename(columns={match: expected})

    print(f"  using cols: {tb_v}, {n_v}, {tb_nv}, {n_nv}")
    df = df.dropna(subset=[tb_v, n_v, tb_nv, n_nv])
    for c in [tb_v, n_v, tb_nv, n_nv]:
        df[c] = pd.to_numeric(df[c], errors="coerce")
    df = df.dropna()

    # Risk ratio per study: RR = (BCGTB/BCGVacc) / (NoVaccTB/NoVacc)
    # log(RR) ± 1.96*SE, SE = sqrt(1/a - 1/n1 + 1/c - 1/n2)
    df["risk_vacc"]   = df[tb_v]  / df[n_v]
    df["risk_novacc"] = df[tb_nv] / df[n_nv]
    df["RR"]          = df["risk_vacc"] / df["risk_novacc"]
    df["logRR"]       = np.log(df["RR"])
    df["SE"]          = np.sqrt(
        1/np.maximum(df[tb_v],1) - 1/np.maximum(df[n_v],1) +
        1/np.maximum(df[tb_nv],1) - 1/np.maximum(df[n_nv],1)
    )
    df["CI_lo"] = np.exp(df["logRR"] - 1.96 * df["SE"])
    df["CI_hi"] = np.exp(df["logRR"] + 1.96 * df["SE"])

    # Pooled (inverse-variance) estimate
    df["weight"] = 1 / df["SE"]**2
    pool_logRR = np.sum(df["weight"] * df["logRR"]) / np.sum(df["weight"])
    pool_SE    = np.sqrt(1 / np.sum(df["weight"]))
    pool_RR    = np.exp(pool_logRR)
    pool_lo    = np.exp(pool_logRR - 1.96 * pool_SE)
    pool_hi    = np.exp(pool_logRR + 1.96 * pool_SE)

    # BCG trial study names (matched by row order in the Rdatasets BCG dataset)
    BCG_STUDY_NAMES = [
        "Aronson (1948)",
        "Ferguson & Simes (1949)",
        "Rosenthal et al. (1960)",
        "Hart & Sutherland (1977)",
        "Frimodt-Møller et al. (1973)",
        "Stein & Aronson (1953)",
        "Vandiviere et al. (1973)",
        "TPT Madanapalle (1980)",
        "Coetzee & Berjak (1968)",
        "Rosenthal et al. (1961)",
        "Comstock et al. (1974)",
        "Comstock & Webster (1969)",
        "Comstock et al. (1976)",
    ]
    if len(df) == len(BCG_STUDY_NAMES):
        labels = BCG_STUDY_NAMES
    else:
        trial_col = next((c for c in df.columns if "trial" in c.lower() or "study" in c.lower() or "author" in c.lower()), None)
        labels = df[trial_col].astype(str).tolist() if trial_col else [f"Trial {i+1}" for i in range(len(df))]

    # Build forest plot (horizontal)
    y_pos = list(range(len(df), 0, -1))   # studies top to bottom

    fig = go.Figure()

    # Study-level points + CI whiskers
    fig.add_trace(go.Scatter(
        x=df["RR"], y=y_pos,
        mode="markers",
        marker=dict(
            color=VIOLET,
            size=[max(6, min(16, w / df["weight"].max() * 16)) for w in df["weight"]],
            symbol="square",
        ),
        error_x=dict(
            type="data",
            symmetric=False,
            array=(df["CI_hi"] - df["RR"]).tolist(),
            arrayminus=(df["RR"] - df["CI_lo"]).tolist(),
            color=VIOLET,
            thickness=1.5,
            width=6,
        ),
        name="Studies",
        hovertemplate="RR=%{x:.2f} [%{customdata[0]:.2f}, %{customdata[1]:.2f}]<extra></extra>",
        customdata=list(zip(df["CI_lo"], df["CI_hi"])),
    ))

    # Pooled diamond
    diamond_x = [pool_lo, pool_RR, pool_hi, pool_RR, pool_lo]
    diamond_y = [0, 0.3, 0, -0.3, 0]
    fig.add_trace(go.Scatter(
        x=diamond_x, y=diamond_y,
        mode="lines", fill="toself",
        fillcolor=PINK, line=dict(color=PINK, width=1),
        name=f"Pooled RR={pool_RR:.2f}",
        hovertemplate=f"Pooled RR={pool_RR:.2f}<extra></extra>",
    ))

    # No-effect line
    fig.add_vline(x=1.0, line=dict(color=GRID, width=1, dash="dash"))

    # Y-axis study labels
    fig.update_layout(
        title=dict(text="Forest Plot — BCG Vaccine vs TB (13 RCTs)", x=0.5),
        xaxis=dict(title="Risk Ratio (log scale)", type="log"),
        yaxis=dict(
            tickvals=y_pos + [0],
            ticktext=labels + ["Pooled"],
            range=[-0.8, len(df) + 0.8],
        ),
        legend=dict(orientation="h", y=1.06),
        margin=dict(t=60, b=60, l=140, r=40),
        height=max(400, len(df) * 28 + 120),
        showlegend=True,
    )
    return fig


if __name__ == "__main__":
    generate()

Made with Plotly