{"spec_id":"funnel-meta-analysis","library":"matplotlib","language":"python","code":"\"\"\" anyplot.ai\nfunnel-meta-analysis: Meta-Analysis Funnel Plot for Publication Bias\nLibrary: matplotlib 3.10.9 | Python 3.13.13\nQuality: 94/100 | Updated: 2026-06-10\n\"\"\"\n\nimport os\n\nimport matplotlib.patheffects as pe\nimport matplotlib.pyplot as plt\nimport numpy as np\n\n\n# Theme\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\"\n\n# Imprint palette — position 1 (inside funnel) and position 5 semantic anchor (outside = bias)\nBRAND = \"#009E73\"  # Imprint palette position 1\nRED = \"#AE3030\"  # Imprint palette position 5 — semantic anchor for bad/error\n\n# Data - 15 RCTs comparing drug vs placebo (log odds ratios)\nnp.random.seed(42)\nn_studies = 15\ntrue_effect = -0.4\n\nstd_errors = np.concatenate(\n    [np.random.uniform(0.05, 0.15, 4), np.random.uniform(0.15, 0.30, 5), np.random.uniform(0.30, 0.55, 6)]\n)\neffect_sizes = true_effect + np.random.normal(0, std_errors)\n\n# Introduce mild asymmetry (publication bias): shift small studies toward significance\nsmall_study_mask = std_errors > 0.35\neffect_sizes[small_study_mask] -= np.random.uniform(0.05, 0.20, small_study_mask.sum())\n\nsummary_effect = np.average(effect_sizes, weights=1 / std_errors**2)\n\n# Funnel boundaries\nse_range = np.linspace(0, 0.60, 200)\nupper_ci = summary_effect + 1.96 * se_range\nlower_ci = summary_effect - 1.96 * se_range\n\n# Classify studies: inside or outside the pseudo 95% CI funnel\nstudy_upper = summary_effect + 1.96 * std_errors\nstudy_lower = summary_effect - 1.96 * std_errors\ninside_funnel = (effect_sizes >= study_lower) & (effect_sizes <= study_upper)\n\n# Weight-proportional marker sizing — standard meta-analysis convention (larger = more precise)\nweights = 1 / std_errors**2\nw_min, w_max = weights.min(), weights.max()\nmarker_sizes = 80 + (weights - w_min) / (w_max - w_min) * 440  # range: 80–520\n\n# Plot\nfig, ax = plt.subplots(figsize=(8, 4.5), dpi=400, facecolor=PAGE_BG)\nax.set_facecolor(PAGE_BG)\n\n# Funnel region\nax.fill_betweenx(se_range, lower_ci, upper_ci, color=BRAND, alpha=0.06)\nax.plot(upper_ci, se_range, color=BRAND, linewidth=1.8, alpha=0.5, linestyle=\"--\")\nax.plot(lower_ci, se_range, color=BRAND, linewidth=1.8, alpha=0.5, linestyle=\"--\")\n\n# Reference lines\nsummary_line = ax.axvline(x=summary_effect, color=INK, linewidth=2.0, alpha=0.85, label=\"Summary effect\")\nsummary_line.set_path_effects([pe.Stroke(linewidth=3.5, foreground=PAGE_BG, alpha=0.5), pe.Normal()])\nax.axvline(x=0, color=INK_SOFT, linewidth=1.5, linestyle=\":\", alpha=0.6, label=\"Null effect (OR=1)\")\n\n# Studies — color by funnel position, sized by inverse-variance weight\nax.scatter(\n    effect_sizes[inside_funnel],\n    std_errors[inside_funnel],\n    s=marker_sizes[inside_funnel],\n    color=BRAND,\n    edgecolors=PAGE_BG,\n    linewidth=0.8,\n    zorder=5,\n    alpha=0.85,\n    label=\"Inside funnel\",\n)\nax.scatter(\n    effect_sizes[~inside_funnel],\n    std_errors[~inside_funnel],\n    s=marker_sizes[~inside_funnel],\n    color=RED,\n    edgecolors=PAGE_BG,\n    linewidth=0.8,\n    zorder=5,\n    alpha=0.85,\n    marker=\"D\",\n    label=\"Outside funnel\",\n)\n\n# Annotate the most prominent outlier suggesting publication bias\nif (~inside_funnel).any():\n    outlier_idx = np.where(~inside_funnel)[0]\n    left_outliers = outlier_idx[effect_sizes[outlier_idx] < summary_effect]\n    if len(left_outliers) > 0:\n        target = left_outliers[np.argmax(std_errors[left_outliers])]\n    else:\n        target = outlier_idx[np.argmax(np.abs(effect_sizes[outlier_idx] - summary_effect))]\n    ax.annotate(\n        \"Potential\\npublication bias\",\n        xy=(effect_sizes[target], std_errors[target]),\n        xytext=(effect_sizes[target] + 0.35, std_errors[target] + 0.06),\n        fontsize=9,\n        color=RED,\n        fontweight=\"medium\",\n        ha=\"left\",\n        arrowprops={\"arrowstyle\": \"->\", \"color\": RED, \"lw\": 1.5, \"connectionstyle\": \"arc3,rad=-0.2\"},\n        path_effects=[pe.withStroke(linewidth=3, foreground=PAGE_BG)],\n    )\n\n# Subtle y-axis grid only\nax.yaxis.grid(True, alpha=0.15, linewidth=0.8, color=INK)\n\n# Style\nax.set_ylim(0.65, 0)  # inverted: SE=0 (precise studies) at top, larger SE at bottom\n\nax.set_xlabel(\"Log Odds Ratio\", fontsize=10, color=INK)\nax.set_ylabel(\"Standard Error\", fontsize=10, color=INK)\n\ntitle = \"funnel-meta-analysis · python · matplotlib · anyplot.ai\"\ntitle_fontsize = max(8, round(12 * 67 / len(title))) if len(title) > 67 else 12\nax.set_title(\n    title,\n    fontsize=title_fontsize,\n    fontweight=\"medium\",\n    color=INK,\n    path_effects=[pe.withStroke(linewidth=4, foreground=PAGE_BG, alpha=0.6)],\n)\n\nax.tick_params(axis=\"both\", labelsize=8, colors=INK_SOFT, labelcolor=INK_SOFT)\nax.spines[\"top\"].set_visible(False)\nax.spines[\"right\"].set_visible(False)\nfor s in (\"left\", \"bottom\"):\n    ax.spines[s].set_color(INK_SOFT)\n\nleg = ax.legend(fontsize=8, loc=\"upper right\")\nleg.get_frame().set_facecolor(ELEVATED_BG)\nleg.get_frame().set_edgecolor(INK_SOFT)\nplt.setp(leg.get_texts(), color=INK_SOFT)\n\nfig.subplots_adjust(left=0.10, right=0.95, top=0.91, bottom=0.13)\nplt.savefig(f\"plot-{THEME}.png\", dpi=400, facecolor=PAGE_BG)\n"}