{"spec_id":"funnel-meta-analysis","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nfunnel-meta-analysis: Meta-Analysis Funnel Plot for Publication Bias\nLibrary: altair 6.2.1 | Python 3.13.13\nQuality: 88/100 | Updated: 2026-06-10\n\"\"\"\n\nimport importlib\nimport os\nimport sys\n\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\n\n# Drop script directory from sys.path so `altair` resolves the package, not this file\nsys.path[:] = [p for p in sys.path if os.path.abspath(p or \".\") != os.path.dirname(os.path.abspath(__file__))]\nalt = importlib.import_module(\"altair\")\n\n# Theme setup — Imprint palette chrome\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 categorical palette — semantic assignments\nCOLOR_WITHIN = \"#009E73\"  # brand green — studies within funnel (typical)\nCOLOR_OUTSIDE = \"#AE3030\"  # matte red — studies outside funnel (potential bias)\nCOLOR_FUNNEL = \"#4467A3\"  # blue — funnel confidence structure (reference)\n\n# Data: 15 RCTs comparing drug vs placebo (log odds ratios)\nnp.random.seed(42)\n\nstudies = [\n    \"Adams 2016\",\n    \"Baker 2017\",\n    \"Chen 2017\",\n    \"Davis 2018\",\n    \"Evans 2018\",\n    \"Foster 2019\",\n    \"Garcia 2019\",\n    \"Harris 2020\",\n    \"Ibrahim 2020\",\n    \"Jones 2021\",\n    \"Klein 2021\",\n    \"Lopez 2022\",\n    \"Mitchell 2022\",\n    \"Nelson 2023\",\n    \"O'Brien 2023\",\n]\n\neffect_sizes = np.array(\n    [-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]\n)\n\nstd_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])\n\n# Summary effect (inverse-variance weighted mean)\nweights = 1.0 / std_errors**2\nsummary_effect = np.sum(weights * effect_sizes) / np.sum(weights)\n\n# Classify studies as inside or outside the funnel (pseudo 95% CI)\nlower_bound = summary_effect - 1.96 * std_errors\nupper_bound = summary_effect + 1.96 * std_errors\ninside_funnel = (effect_sizes >= lower_bound) & (effect_sizes <= upper_bound)\n\ndf = pd.DataFrame(\n    {\n        \"study\": studies,\n        \"effect_size\": effect_sizes,\n        \"std_error\": std_errors,\n        \"weight\": weights / weights.max(),\n        \"region\": np.where(inside_funnel, \"Within funnel\", \"Outside funnel\"),\n    }\n)\n\n# Funnel confidence limits (pseudo 95% CI)\nse_max = max(std_errors) + 0.02\nse_range = np.linspace(0, se_max, 100)\nfunnel_df = pd.DataFrame(\n    {\"se\": se_range, \"lower\": summary_effect - 1.96 * se_range, \"upper\": summary_effect + 1.96 * se_range}\n)\n\ny_scale = alt.Scale(domain=[se_max, 0])\n\n# Funnel confidence area fill\nfunnel_area = (\n    alt.Chart(funnel_df)\n    .transform_fold([\"lower\", \"upper\"], as_=[\"bound\", \"value\"])\n    .mark_area(opacity=0.07, color=COLOR_FUNNEL)\n    .encode(x=alt.X(\"value:Q\"), y=alt.Y(\"se:Q\", scale=y_scale), detail=\"bound:N\")\n)\n\n# Funnel confidence bounds (dashed lines)\nfunnel_left = (\n    alt.Chart(funnel_df)\n    .mark_line(color=COLOR_FUNNEL, strokeDash=[6, 3], strokeWidth=1.5, opacity=0.5)\n    .encode(x=alt.X(\"lower:Q\"), y=alt.Y(\"se:Q\", scale=y_scale))\n)\n\nfunnel_right = (\n    alt.Chart(funnel_df)\n    .mark_line(color=COLOR_FUNNEL, strokeDash=[6, 3], strokeWidth=1.5, opacity=0.5)\n    .encode(x=alt.X(\"upper:Q\"), y=alt.Y(\"se:Q\", scale=y_scale))\n)\n\n# Summary effect vertical line\nsummary_line = (\n    alt.Chart(pd.DataFrame({\"x\": [summary_effect]}))\n    .mark_rule(color=COLOR_FUNNEL, strokeWidth=2.5, opacity=0.8)\n    .encode(x=\"x:Q\")\n)\n\n# Null effect reference line at 0\nnull_line = (\n    alt.Chart(pd.DataFrame({\"x\": [0]}))\n    .mark_rule(color=INK_MUTED, strokeDash=[8, 4], strokeWidth=1.5, opacity=0.7)\n    .encode(x=\"x:Q\")\n)\n\n# Color-coded study points sized by inverse-variance weight\nregion_color = alt.Color(\n    \"region:N\",\n    scale=alt.Scale(domain=[\"Within funnel\", \"Outside funnel\"], range=[COLOR_WITHIN, COLOR_OUTSIDE]),\n    legend=alt.Legend(title=None, orient=\"bottom-right\", labelFontSize=10, symbolSize=100),\n)\n\n# Redundant shape encoding for CVD accessibility (circle vs diamond)\nregion_shape = alt.Shape(\n    \"region:N\", scale=alt.Scale(domain=[\"Within funnel\", \"Outside funnel\"], range=[\"circle\", \"diamond\"]), legend=None\n)\n\npoints = (\n    alt.Chart(df)\n    .mark_point(filled=True, stroke=\"white\", strokeWidth=1.5, opacity=0.9)\n    .encode(\n        x=alt.X(\"effect_size:Q\", title=\"Log Odds Ratio\", scale=alt.Scale(domain=[-1.15, 0.35])),\n        y=alt.Y(\"std_error:Q\", title=\"Standard Error\", scale=y_scale),\n        color=region_color,\n        shape=region_shape,\n        size=alt.Size(\"weight:Q\", scale=alt.Scale(range=[160, 520]), legend=None),\n        tooltip=[\n            alt.Tooltip(\"study:N\", title=\"Study\"),\n            alt.Tooltip(\"effect_size:Q\", title=\"Log OR\", format=\".2f\"),\n            alt.Tooltip(\"std_error:Q\", title=\"SE\", format=\".3f\"),\n            alt.Tooltip(\"region:N\", title=\"Region\"),\n        ],\n    )\n)\n\nchart = (\n    alt.layer(funnel_area, funnel_left, funnel_right, null_line, summary_line, points)\n    .properties(\n        width=620,\n        height=320,\n        background=PAGE_BG,\n        title=alt.Title(\n            \"funnel-meta-analysis · python · altair · anyplot.ai\",\n            fontSize=16,\n            anchor=\"middle\",\n            color=INK,\n            subtitle=\"Asymmetry suggests publication bias — red points fall outside the 95% pseudo-confidence funnel\",\n            subtitleFontSize=10,\n            subtitleColor=INK_SOFT,\n        ),\n    )\n    .configure_view(fill=PAGE_BG, stroke=INK_SOFT, continuousWidth=620, continuousHeight=320)\n    .configure_axis(\n        labelFontSize=10,\n        titleFontSize=12,\n        titleColor=INK,\n        labelColor=INK_SOFT,\n        domainColor=INK_SOFT,\n        tickColor=INK_SOFT,\n        gridColor=INK,\n        gridOpacity=0.15,\n    )\n    .configure_axisX(grid=False)\n    .configure_axisY(grid=False)\n    .configure_legend(\n        padding=8, cornerRadius=4, fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, labelFontSize=10\n    )\n)\n\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n\n# Pad-only to exact canvas target (3200 × 1800 landscape)\nTW, TH = 3200, 1800\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. \"\n        \"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n\nchart.save(f\"plot-{THEME}.html\")\n"}