{"spec_id":"funnel-meta-analysis","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\nfunnel-meta-analysis: Meta-Analysis Funnel Plot for Publication Bias\nLibrary: letsplot 4.10.1 | Python 3.13.13\nQuality: 90/100 | Updated: 2026-06-10\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nfrom lets_plot import *\n\n\nLetsPlot.setup_html()\n\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\nGRID_COLOR = \"#D9D7D0\" if THEME == \"light\" else \"#3A3A36\"\n\n# Imprint palette — first series always #009E73\nBRAND = \"#009E73\"  # Inside funnel (expected, within confidence limits)\nOUTLIER = \"#AE3030\"  # Outside funnel — semantic anchor: potential bias / outlier\n\n# Data: Meta-analysis of 15 RCTs comparing drug vs placebo\n# Effect sizes are log odds ratios; null effect at 0\nstudies = [\n    {\"study\": \"Adams 2015\", \"effect_size\": 0.42, \"std_error\": 0.18, \"n\": 120},\n    {\"study\": \"Baker 2016\", \"effect_size\": 0.28, \"std_error\": 0.22, \"n\": 85},\n    {\"study\": \"Chen 2016\", \"effect_size\": 0.65, \"std_error\": 0.30, \"n\": 48},\n    {\"study\": \"Davis 2017\", \"effect_size\": -0.08, \"std_error\": 0.25, \"n\": 64},\n    {\"study\": \"Evans 2017\", \"effect_size\": 0.52, \"std_error\": 0.12, \"n\": 280},\n    {\"study\": \"Foster 2018\", \"effect_size\": 0.10, \"std_error\": 0.35, \"n\": 34},\n    {\"study\": \"Garcia 2018\", \"effect_size\": 0.38, \"std_error\": 0.15, \"n\": 180},\n    {\"study\": \"Hughes 2019\", \"effect_size\": 0.55, \"std_error\": 0.28, \"n\": 52},\n    {\"study\": \"Ito 2019\", \"effect_size\": 0.30, \"std_error\": 0.10, \"n\": 410},\n    {\"study\": \"Jensen 2020\", \"effect_size\": 1.05, \"std_error\": 0.32, \"n\": 40},\n    {\"study\": \"Klein 2020\", \"effect_size\": -0.15, \"std_error\": 0.14, \"n\": 205},\n    {\"study\": \"Lee 2021\", \"effect_size\": 0.48, \"std_error\": 0.20, \"n\": 100},\n    {\"study\": \"Morgan 2021\", \"effect_size\": 0.33, \"std_error\": 0.16, \"n\": 160},\n    {\"study\": \"Nguyen 2022\", \"effect_size\": 0.88, \"std_error\": 0.30, \"n\": 46},\n    {\"study\": \"Olsen 2023\", \"effect_size\": -0.05, \"std_error\": 0.38, \"n\": 28},\n]\n\ndf = pd.DataFrame(studies)\n\n# Inverse-variance weights and pooled effect estimate\nweights = 1 / df[\"std_error\"] ** 2\npooled_effect = (df[\"effect_size\"] * weights).sum() / weights.sum()\ndf[\"iw\"] = weights\n\n# Classify studies: inside or outside the 95% funnel boundary\ndf[\"position\"] = np.where(\n    (df[\"effect_size\"] >= pooled_effect - 1.96 * df[\"std_error\"])\n    & (df[\"effect_size\"] <= pooled_effect + 1.96 * df[\"std_error\"]),\n    \"Inside funnel\",\n    \"Outside funnel\",\n)\n\n# Pseudo 95% confidence funnel boundary\nse_max = df[\"std_error\"].max() + 0.05\nse_range = np.linspace(0, se_max, 200)\nfunnel_upper = pooled_effect + 1.96 * se_range\nfunnel_lower = pooled_effect - 1.96 * se_range\n\nfunnel_df = pd.DataFrame(\n    {\"x\": np.concatenate([funnel_lower, funnel_upper[::-1]]), \"y\": np.concatenate([se_range, se_range[::-1]])}\n)\n\nfunnel_lines_df = pd.DataFrame(\n    {\n        \"x\": np.concatenate([funnel_lower, funnel_upper]),\n        \"y\": np.concatenate([se_range, se_range]),\n        \"side\": [\"lower\"] * len(se_range) + [\"upper\"] * len(se_range),\n    }\n)\n\n# Pooled OR annotation (single-row data frame for geom_label)\nannotation_df = pd.DataFrame(\n    {\"x\": [pooled_effect + 0.04], \"y\": [0.012], \"label\": [f\"Pooled OR = {np.exp(pooled_effect):.2f}\"]}\n)\n\n# Top 3 highest-weight inside-funnel studies for labeling\ninside_top = df[df[\"position\"] == \"Inside funnel\"].nlargest(3, \"iw\")\n\ntitle = \"funnel-meta-analysis · python · letsplot · anyplot.ai\"\ntitle_size = round(16 * min(1.0, 67 / len(title)))\n\nplot = (\n    ggplot()\n    # Funnel confidence region (shaded polygon)\n    + geom_polygon(aes(x=\"x\", y=\"y\"), data=funnel_df, fill=BRAND, alpha=0.07)\n    # Funnel boundary lines (95% CI dashed)\n    + geom_line(\n        aes(x=\"x\", y=\"y\", group=\"side\"), data=funnel_lines_df, color=BRAND, size=0.8, linetype=\"dashed\", alpha=0.5\n    )\n    # Null effect reference line\n    + geom_vline(xintercept=0, color=INK_MUTED, size=0.6, linetype=\"dashed\", alpha=0.7)\n    # Pooled effect line\n    + geom_vline(xintercept=pooled_effect, color=INK, size=1.1, alpha=0.85)\n    # Study points — sized by inverse-variance weight, colored by classification\n    + geom_point(\n        aes(x=\"effect_size\", y=\"std_error\", color=\"position\", size=\"iw\"),\n        data=df,\n        shape=16,\n        alpha=0.88,\n        tooltips=layer_tooltips()\n        .title(\"@study\")\n        .line(\"Effect (log OR)|@effect_size{.3f}\")\n        .line(\"Std. error|@std_error{.3f}\")\n        .line(\"Sample size|@n\")\n        .line(\"Status|@position\"),\n    )\n    + scale_color_manual(values={\"Inside funnel\": BRAND, \"Outside funnel\": OUTLIER}, name=\"Classification\")\n    + scale_size(range=[2.0, 7.0], guide=\"none\")\n    # Outlier labels with filled background — right-align Jensen 2020 to prevent canvas overflow\n    + geom_label(\n        aes(x=\"effect_size\", y=\"std_error\", label=\"study\"),\n        data=df[df[\"study\"] == \"Jensen 2020\"],\n        color=OUTLIER,\n        fill=ELEVATED_BG,\n        size=3.2,\n        nudge_y=-0.022,\n        hjust=1.1,\n        fontface=\"bold\",\n        show_legend=False,\n    )\n    + geom_label(\n        aes(x=\"effect_size\", y=\"std_error\", label=\"study\"),\n        data=df[df[\"study\"] == \"Klein 2020\"],\n        color=OUTLIER,\n        fill=ELEVATED_BG,\n        size=3.2,\n        nudge_y=-0.022,\n        hjust=-0.15,\n        fontface=\"bold\",\n        show_legend=False,\n    )\n    # Top-weight inside-funnel study labels\n    + geom_text(\n        aes(x=\"effect_size\", y=\"std_error\", label=\"study\"),\n        data=inside_top,\n        color=INK_SOFT,\n        size=2.8,\n        nudge_y=-0.018,\n        show_legend=False,\n    )\n    # Pooled OR annotation box\n    + geom_label(\n        aes(x=\"x\", y=\"y\", label=\"label\"),\n        data=annotation_df,\n        color=INK,\n        fill=ELEVATED_BG,\n        size=3.5,\n        fontface=\"bold\",\n        hjust=0,\n        show_legend=False,\n    )\n    # Inverted y-axis: lower SE (higher precision) at the top\n    + scale_y_reverse()\n    + labs(x=\"Log Odds Ratio\", y=\"Standard Error (precision ↑)\", title=title)\n    + ggsize(800, 450)\n    + theme_minimal()\n    + theme(\n        plot_title=element_text(size=title_size, face=\"bold\", color=INK),\n        axis_title=element_text(size=12, color=INK),\n        axis_text=element_text(size=10, color=INK_SOFT),\n        panel_grid_major_x=element_blank(),\n        panel_grid_major_y=element_line(color=GRID_COLOR, size=0.3),\n        panel_grid_minor=element_blank(),\n        axis_line=element_line(color=INK_SOFT, size=0.4),\n        legend_position=[0.85, 0.82],\n        legend_title=element_text(size=10, face=\"bold\", color=INK),\n        legend_text=element_text(size=10, color=INK_SOFT),\n        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT, size=0.5),\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        panel_background=element_rect(fill=PAGE_BG),\n    )\n)\n\nggsave(plot, f\"plot-{THEME}.png\", scale=4, path=\".\")\nggsave(plot, f\"plot-{THEME}.html\", path=\".\")\n"}