|
| 1 | +""" pyplots.ai |
| 2 | +forest-basic: Meta-Analysis Forest Plot |
| 3 | +Library: altair 6.0.0 | Python 3.13.11 |
| 4 | +Quality: 91/100 | Created: 2025-12-27 |
| 5 | +""" |
| 6 | + |
| 7 | +import altair as alt |
| 8 | +import pandas as pd |
| 9 | + |
| 10 | + |
| 11 | +# Data: Meta-analysis of RCTs comparing treatment efficacy (standardized mean difference) |
| 12 | +studies = [ |
| 13 | + "Johnson 2018", |
| 14 | + "Smith 2019", |
| 15 | + "Garcia 2020", |
| 16 | + "Williams 2020", |
| 17 | + "Brown 2021", |
| 18 | + "Davis 2021", |
| 19 | + "Miller 2022", |
| 20 | + "Wilson 2022", |
| 21 | + "Anderson 2023", |
| 22 | + "Taylor 2023", |
| 23 | +] |
| 24 | + |
| 25 | +effect_sizes = [-0.45, -0.32, -0.58, -0.21, -0.67, -0.38, -0.52, -0.29, -0.41, -0.55] |
| 26 | +ci_lower = [-0.78, -0.61, -0.95, -0.48, -1.02, -0.69, -0.88, -0.56, -0.72, -0.91] |
| 27 | +ci_upper = [-0.12, -0.03, -0.21, 0.06, -0.32, -0.07, -0.16, -0.02, -0.10, -0.19] |
| 28 | +weights = [8.5, 10.2, 7.8, 11.5, 6.9, 9.3, 8.1, 10.8, 9.7, 7.2] |
| 29 | + |
| 30 | +# Pooled estimate |
| 31 | +pooled_effect = -0.42 |
| 32 | +pooled_ci_lower = -0.53 |
| 33 | +pooled_ci_upper = -0.31 |
| 34 | + |
| 35 | +# Order labels for y-axis (studies at top, pooled estimate at bottom) |
| 36 | +y_labels = list(reversed(studies)) + ["Pooled Estimate"] |
| 37 | + |
| 38 | +# Create DataFrame for studies |
| 39 | +df_studies = pd.DataFrame( |
| 40 | + {"study": studies, "effect_size": effect_sizes, "ci_lower": ci_lower, "ci_upper": ci_upper, "weight": weights} |
| 41 | +) |
| 42 | + |
| 43 | +# Normalize weights for marker sizing (scale between 150 and 500 for visibility) |
| 44 | +weight_min = min(weights) |
| 45 | +weight_max = max(weights) |
| 46 | +df_studies["marker_size"] = 150 + (df_studies["weight"] - weight_min) / (weight_max - weight_min) * 350 |
| 47 | + |
| 48 | +# Create DataFrame for pooled estimate (CI line and diamond point) |
| 49 | +df_pooled = pd.DataFrame( |
| 50 | + { |
| 51 | + "study": ["Pooled Estimate"], |
| 52 | + "effect_size": [pooled_effect], |
| 53 | + "ci_lower": [pooled_ci_lower], |
| 54 | + "ci_upper": [pooled_ci_upper], |
| 55 | + } |
| 56 | +) |
| 57 | + |
| 58 | +# Vertical reference line at null effect (0) |
| 59 | +reference_line = ( |
| 60 | + alt.Chart(pd.DataFrame({"x": [0]})) |
| 61 | + .mark_rule(color="#888888", strokeDash=[8, 4], strokeWidth=2, opacity=0.7) |
| 62 | + .encode(x="x:Q") |
| 63 | +) |
| 64 | + |
| 65 | +# Confidence interval lines for studies |
| 66 | +ci_lines = ( |
| 67 | + alt.Chart(df_studies) |
| 68 | + .mark_rule(color="#306998", strokeWidth=4) |
| 69 | + .encode( |
| 70 | + x=alt.X("ci_lower:Q", scale=alt.Scale(domain=[-1.15, 0.15])), x2="ci_upper:Q", y=alt.Y("study:N", sort=y_labels) |
| 71 | + ) |
| 72 | +) |
| 73 | + |
| 74 | +# Effect size points for studies |
| 75 | +points = ( |
| 76 | + alt.Chart(df_studies) |
| 77 | + .mark_point(filled=True, color="#306998", stroke="white", strokeWidth=2) |
| 78 | + .encode( |
| 79 | + x=alt.X("effect_size:Q", scale=alt.Scale(domain=[-1.15, 0.15])), |
| 80 | + y=alt.Y("study:N", sort=y_labels), |
| 81 | + size=alt.Size("marker_size:Q", legend=None, scale=alt.Scale(range=[150, 500])), |
| 82 | + tooltip=[ |
| 83 | + alt.Tooltip("study:N", title="Study"), |
| 84 | + alt.Tooltip("effect_size:Q", title="Effect Size", format=".2f"), |
| 85 | + alt.Tooltip("ci_lower:Q", title="CI Lower", format=".2f"), |
| 86 | + alt.Tooltip("ci_upper:Q", title="CI Upper", format=".2f"), |
| 87 | + ], |
| 88 | + ) |
| 89 | +) |
| 90 | + |
| 91 | +# Pooled estimate confidence interval line |
| 92 | +pooled_ci = ( |
| 93 | + alt.Chart(df_pooled) |
| 94 | + .mark_rule(color="#306998", strokeWidth=4) |
| 95 | + .encode( |
| 96 | + x=alt.X("ci_lower:Q", scale=alt.Scale(domain=[-1.15, 0.15])), x2="ci_upper:Q", y=alt.Y("study:N", sort=y_labels) |
| 97 | + ) |
| 98 | +) |
| 99 | + |
| 100 | +# Diamond marker for pooled estimate using mark_point with shape |
| 101 | +pooled_diamond = ( |
| 102 | + alt.Chart(df_pooled) |
| 103 | + .mark_point(shape="diamond", filled=True, size=1500, color="#FFD43B", stroke="#306998", strokeWidth=3) |
| 104 | + .encode( |
| 105 | + x=alt.X("effect_size:Q", scale=alt.Scale(domain=[-1.15, 0.15])), |
| 106 | + y=alt.Y("study:N", sort=y_labels), |
| 107 | + tooltip=[ |
| 108 | + alt.Tooltip("study:N", title="Estimate"), |
| 109 | + alt.Tooltip("effect_size:Q", title="Effect Size", format=".2f"), |
| 110 | + alt.Tooltip("ci_lower:Q", title="CI Lower", format=".2f"), |
| 111 | + alt.Tooltip("ci_upper:Q", title="CI Upper", format=".2f"), |
| 112 | + ], |
| 113 | + ) |
| 114 | +) |
| 115 | + |
| 116 | +# Text annotations for "Favors Treatment" and "Favors Control" |
| 117 | +annotation_left = ( |
| 118 | + alt.Chart(pd.DataFrame({"x": [-1.05], "study": ["Pooled Estimate"], "text": ["← Favors Treatment"]})) |
| 119 | + .mark_text(fontSize=16, color="#555555", align="left", dy=35) |
| 120 | + .encode(x=alt.X("x:Q", scale=alt.Scale(domain=[-1.15, 0.15])), y=alt.Y("study:N", sort=y_labels), text="text:N") |
| 121 | +) |
| 122 | + |
| 123 | +annotation_right = ( |
| 124 | + alt.Chart(pd.DataFrame({"x": [0.05], "study": ["Pooled Estimate"], "text": ["Favors Control →"]})) |
| 125 | + .mark_text(fontSize=16, color="#555555", align="right", dy=35) |
| 126 | + .encode(x=alt.X("x:Q", scale=alt.Scale(domain=[-1.15, 0.15])), y=alt.Y("study:N", sort=y_labels), text="text:N") |
| 127 | +) |
| 128 | + |
| 129 | +# Combine all layers |
| 130 | +chart = ( |
| 131 | + alt.layer(reference_line, ci_lines, points, pooled_ci, pooled_diamond, annotation_left, annotation_right) |
| 132 | + .properties( |
| 133 | + width=1600, height=900, title=alt.Title("forest-basic · altair · pyplots.ai", fontSize=28, anchor="middle") |
| 134 | + ) |
| 135 | + .configure_axis(labelFontSize=18, titleFontSize=22) |
| 136 | + .configure_axisX(title="Standardized Mean Difference (95% CI)", grid=True, gridOpacity=0.3, gridDash=[4, 4]) |
| 137 | + .configure_axisY(title=None, grid=False, ticks=False, domain=False) |
| 138 | + .configure_view(strokeWidth=0) |
| 139 | +) |
| 140 | + |
| 141 | +# Save as PNG and HTML |
| 142 | +chart.save("plot.png", scale_factor=3.0) |
| 143 | +chart.save("plot.html") |
0 commit comments