PyMetaAnalysis 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,193 @@
1
+ """Matplotlib forest plots built from stable result objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ import numpy as np
8
+ from scipy.stats import norm
9
+
10
+ from ..results import MetaAnalysisResult
11
+ from ._utils import (
12
+ configure_log_axis,
13
+ default_effect_label,
14
+ marker_areas,
15
+ to_display_scale,
16
+ )
17
+
18
+ if TYPE_CHECKING:
19
+ from matplotlib.axes import Axes
20
+ else:
21
+ Axes = Any
22
+
23
+
24
+ def _default_pooled_label(result: MetaAnalysisResult) -> str:
25
+ return "Common effect" if result.model == "common" else "Random effects"
26
+
27
+
28
+ def forest_plot(
29
+ result: MetaAnalysisResult,
30
+ *,
31
+ ax: Axes | None = None,
32
+ effect_label: str | None = None,
33
+ pooled_label: str | None = None,
34
+ show_prediction_interval: bool = True,
35
+ show_weights: bool = True,
36
+ null_value: float | None = None,
37
+ log_scale: bool | None = None,
38
+ ) -> Axes:
39
+ """Draw a forest plot and return its Matplotlib ``Axes``.
40
+
41
+ Only included studies are plotted. Study confidence intervals use the
42
+ result confidence level and normal critical value. Ratio measures are
43
+ displayed on their exponentiated scale with a logarithmic axis by default.
44
+ The function never calls ``show()``.
45
+ """
46
+
47
+ try:
48
+ import matplotlib.pyplot as plt
49
+ from matplotlib.patches import Polygon
50
+ except ImportError as error: # pragma: no cover - tested without plot extra
51
+ raise ImportError(
52
+ "Forest plots require Matplotlib; install PyMetaAnalysis[plot]."
53
+ ) from error
54
+
55
+ studies = result.study_results
56
+ included = studies.loc[studies["included"]].reset_index(drop=True)
57
+ if included.empty:
58
+ raise ValueError("Forest plot requires at least one included study.")
59
+
60
+ effect = included["effect"].to_numpy(dtype=np.float64, copy=True)
61
+ variance = included["variance"].to_numpy(dtype=np.float64, copy=True)
62
+ weights = included["normalized_weight"].to_numpy(dtype=np.float64, copy=True)
63
+ critical_value = float(norm.ppf(0.5 + result.method.confidence_level / 2.0))
64
+ margin = critical_value * np.sqrt(variance)
65
+ study_low = to_display_scale(effect - margin, display_scale=result.display_scale)
66
+ study_estimate = to_display_scale(effect, display_scale=result.display_scale)
67
+ study_high = to_display_scale(effect + margin, display_scale=result.display_scale)
68
+ pooled_low, pooled_estimate, pooled_high = to_display_scale(
69
+ np.asarray([result.ci_low, result.estimate, result.ci_high]),
70
+ display_scale=result.display_scale,
71
+ )
72
+
73
+ use_log_scale = result.display_scale == "exp" if log_scale is None else log_scale
74
+ resolved_null = (
75
+ (1.0 if result.display_scale == "exp" else 0.0)
76
+ if null_value is None
77
+ else float(null_value)
78
+ )
79
+ if not np.isfinite(resolved_null) or (use_log_scale and resolved_null <= 0.0):
80
+ raise ValueError(
81
+ "null_value must be finite and strictly positive on a logarithmic axis."
82
+ )
83
+
84
+ created_axes = ax is None
85
+ if ax is None:
86
+ height = max(4.0, 0.5 * (len(included) + 3))
87
+ _, ax = plt.subplots(figsize=(8.0, height))
88
+ if use_log_scale:
89
+ ax.set_xscale("log")
90
+ configure_log_axis(ax)
91
+
92
+ y_studies = np.arange(len(included), 0, -1, dtype=np.float64)
93
+ pooled_y = 0.0
94
+ ax.axvline(
95
+ resolved_null,
96
+ color="#777777",
97
+ linestyle="--",
98
+ linewidth=1.0,
99
+ zorder=0,
100
+ )
101
+ ax.hlines(
102
+ y_studies,
103
+ study_low,
104
+ study_high,
105
+ colors="#4c4c4c",
106
+ linewidth=1.2,
107
+ zorder=1,
108
+ )
109
+ ax.scatter(
110
+ study_estimate,
111
+ y_studies,
112
+ s=marker_areas(weights),
113
+ marker="s",
114
+ color="#2f6f9f",
115
+ edgecolors="white",
116
+ linewidths=0.6,
117
+ zorder=2,
118
+ )
119
+
120
+ if show_prediction_interval and result.prediction_interval is not None:
121
+ prediction_low, prediction_high = to_display_scale(
122
+ np.asarray(result.prediction_interval, dtype=np.float64),
123
+ display_scale=result.display_scale,
124
+ )
125
+ ax.hlines(
126
+ pooled_y,
127
+ prediction_low,
128
+ prediction_high,
129
+ colors="#d97706",
130
+ linewidth=2.0,
131
+ zorder=1,
132
+ )
133
+ ax.vlines(
134
+ [prediction_low, prediction_high],
135
+ pooled_y - 0.12,
136
+ pooled_y + 0.12,
137
+ colors="#d97706",
138
+ linewidth=1.5,
139
+ zorder=1,
140
+ )
141
+
142
+ diamond = Polygon(
143
+ [
144
+ (pooled_low, pooled_y),
145
+ (pooled_estimate, pooled_y + 0.25),
146
+ (pooled_high, pooled_y),
147
+ (pooled_estimate, pooled_y - 0.25),
148
+ ],
149
+ closed=True,
150
+ facecolor="#1f4e79",
151
+ edgecolor="#1f4e79",
152
+ zorder=3,
153
+ )
154
+ ax.add_patch(diamond)
155
+
156
+ labels = [str(label) for label in included["study"]]
157
+ labels.append(pooled_label or _default_pooled_label(result))
158
+ ax.set_yticks(np.concatenate([y_studies, [pooled_y]]), labels=labels)
159
+ ax.set_ylim(-0.65, len(included) + 0.75)
160
+ ax.set_xlabel(effect_label or default_effect_label(result))
161
+ ax.tick_params(axis="y", length=0)
162
+ ax.grid(axis="x", color="#dddddd", linewidth=0.7, alpha=0.8)
163
+ ax.set_axisbelow(True)
164
+ for side in ("top", "right", "left"):
165
+ ax.spines[side].set_visible(False)
166
+ ax.margins(x=0.08)
167
+
168
+ if show_weights:
169
+ transform = ax.get_yaxis_transform()
170
+ header_y = len(included) + 0.55
171
+ ax.text(
172
+ 1.02,
173
+ header_y,
174
+ "Weight",
175
+ transform=transform,
176
+ ha="left",
177
+ va="center",
178
+ fontweight="bold",
179
+ clip_on=False,
180
+ )
181
+ for y_value, weight in zip(y_studies, weights, strict=True):
182
+ ax.text(
183
+ 1.02,
184
+ y_value,
185
+ f"{100.0 * weight:.1f}%",
186
+ transform=transform,
187
+ ha="left",
188
+ va="center",
189
+ clip_on=False,
190
+ )
191
+ if created_axes:
192
+ ax.figure.subplots_adjust(left=0.25, right=0.82, bottom=0.14)
193
+ return ax
@@ -0,0 +1,168 @@
1
+ """Matplotlib funnel plots built from stable result objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import numpy as np
9
+ from scipy.stats import norm
10
+
11
+ from ..results import MetaAnalysisResult
12
+ from ._utils import configure_log_axis, default_effect_label, to_display_scale
13
+
14
+ if TYPE_CHECKING:
15
+ from matplotlib.axes import Axes
16
+ else:
17
+ Axes = Any
18
+
19
+
20
+ def _validate_confidence_level(
21
+ confidence_level: float | None, *, fallback: float
22
+ ) -> float:
23
+ resolved = fallback if confidence_level is None else confidence_level
24
+ if isinstance(resolved, bool) or not isinstance(resolved, (int, float)):
25
+ raise ValueError("confidence_level must be a number between 0 and 1.")
26
+ numeric = float(resolved)
27
+ if not np.isfinite(numeric) or not 0.0 < numeric < 1.0:
28
+ raise ValueError("confidence_level must be between 0 and 1.")
29
+ return numeric
30
+
31
+
32
+ def funnel_plot(
33
+ result: MetaAnalysisResult,
34
+ *,
35
+ ax: Axes | None = None,
36
+ effect_label: str | None = None,
37
+ confidence_level: float | None = None,
38
+ show_pseudo_confidence_interval: bool = True,
39
+ warn_on_few_studies: bool = True,
40
+ log_scale: bool | None = None,
41
+ ) -> Axes:
42
+ """Draw a standard-error funnel plot and return its Matplotlib ``Axes``.
43
+
44
+ Pseudo confidence limits are calculated on the model scale around the
45
+ fitted pooled estimate and do not incorporate tau-squared. Ratio measures
46
+ are exponentiated and drawn on a logarithmic x-axis by default. The
47
+ function never calls ``show()``.
48
+ """
49
+
50
+ try:
51
+ import matplotlib.pyplot as plt
52
+ except ImportError as error: # pragma: no cover - tested without plot extra
53
+ raise ImportError(
54
+ "Funnel plots require Matplotlib; install PyMetaAnalysis[plot]."
55
+ ) from error
56
+
57
+ studies = result.study_results
58
+ included = studies.loc[studies["included"]].reset_index(drop=True)
59
+ if included.empty:
60
+ raise ValueError("Funnel plot requires at least one included study.")
61
+ if warn_on_few_studies and len(included) < 10:
62
+ warnings.warn(
63
+ "Funnel plots are difficult to interpret with fewer than 10 studies; "
64
+ "asymmetry indicates possible small-study effects, not necessarily "
65
+ "publication bias.",
66
+ UserWarning,
67
+ stacklevel=2,
68
+ )
69
+
70
+ level = _validate_confidence_level(
71
+ confidence_level, fallback=result.method.confidence_level
72
+ )
73
+ effect = included["effect"].to_numpy(dtype=np.float64, copy=True)
74
+ standard_error = np.sqrt(included["variance"].to_numpy(dtype=np.float64, copy=True))
75
+ displayed_effect = to_display_scale(effect, display_scale=result.display_scale)
76
+ displayed_reference = float(
77
+ to_display_scale(
78
+ np.asarray([result.estimate], dtype=np.float64),
79
+ display_scale=result.display_scale,
80
+ )[0]
81
+ )
82
+
83
+ use_log_scale = result.display_scale == "exp" if log_scale is None else log_scale
84
+ if use_log_scale and (
85
+ np.any(displayed_effect <= 0.0) or displayed_reference <= 0.0
86
+ ):
87
+ raise ValueError(
88
+ "Funnel plot effects and pooled estimate must be strictly positive "
89
+ "on a logarithmic axis."
90
+ )
91
+ created_axes = ax is None
92
+ if ax is None:
93
+ _, ax = plt.subplots(figsize=(6.5, 6.0))
94
+ if use_log_scale:
95
+ ax.set_xscale("log")
96
+ configure_log_axis(ax)
97
+
98
+ maximum_se = float(np.max(standard_error))
99
+ plot_maximum_se = maximum_se * 1.08
100
+ if show_pseudo_confidence_interval:
101
+ critical_value = float(norm.ppf(0.5 + level / 2.0))
102
+ standard_error_grid = np.linspace(0.0, plot_maximum_se, 200)
103
+ lower = to_display_scale(
104
+ result.estimate - critical_value * standard_error_grid,
105
+ display_scale=result.display_scale,
106
+ )
107
+ upper = to_display_scale(
108
+ result.estimate + critical_value * standard_error_grid,
109
+ display_scale=result.display_scale,
110
+ )
111
+ if use_log_scale and (np.any(lower <= 0.0) or np.any(upper <= 0.0)):
112
+ raise ValueError(
113
+ "Funnel plot confidence limits must be strictly positive on a "
114
+ "logarithmic axis."
115
+ )
116
+ ax.fill_betweenx(
117
+ standard_error_grid,
118
+ lower,
119
+ upper,
120
+ color="#dbeafe",
121
+ alpha=0.6,
122
+ zorder=0,
123
+ )
124
+ ax.plot(
125
+ lower,
126
+ standard_error_grid,
127
+ color="#6b7280",
128
+ linestyle="--",
129
+ linewidth=1.0,
130
+ zorder=1,
131
+ )
132
+ ax.plot(
133
+ upper,
134
+ standard_error_grid,
135
+ color="#6b7280",
136
+ linestyle="--",
137
+ linewidth=1.0,
138
+ zorder=1,
139
+ )
140
+
141
+ ax.axvline(
142
+ displayed_reference,
143
+ color="#4b5563",
144
+ linestyle="-",
145
+ linewidth=1.2,
146
+ zorder=1,
147
+ )
148
+ ax.scatter(
149
+ displayed_effect,
150
+ standard_error,
151
+ s=42.0,
152
+ marker="o",
153
+ color="#2f6f9f",
154
+ edgecolors="white",
155
+ linewidths=0.6,
156
+ zorder=2,
157
+ )
158
+ ax.set_ylim(plot_maximum_se, 0.0)
159
+ ax.set_xlabel(effect_label or default_effect_label(result))
160
+ ax.set_ylabel("Standard error")
161
+ ax.grid(axis="y", color="#e5e7eb", linewidth=0.7, alpha=0.8)
162
+ ax.set_axisbelow(True)
163
+ for side in ("top", "right"):
164
+ ax.spines[side].set_visible(False)
165
+ ax.margins(x=0.08)
166
+ if created_axes:
167
+ ax.figure.subplots_adjust(left=0.14, right=0.96, bottom=0.13, top=0.96)
168
+ return ax
@@ -0,0 +1,284 @@
1
+ """Forest plots for independent study subgroup analyses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ import numpy as np
8
+ from numpy.typing import NDArray
9
+ from scipy.stats import norm
10
+
11
+ from ..results import MetaAnalysisResult, SubgroupMetaAnalysisResult
12
+ from ._utils import (
13
+ configure_log_axis,
14
+ default_effect_label,
15
+ marker_areas,
16
+ to_display_scale,
17
+ )
18
+
19
+ if TYPE_CHECKING:
20
+ from matplotlib.axes import Axes
21
+ else:
22
+ Axes = Any
23
+
24
+
25
+ def _draw_interval(
26
+ ax: Axes,
27
+ result: MetaAnalysisResult,
28
+ *,
29
+ y: float,
30
+ display_scale: str,
31
+ ) -> None:
32
+ if result.prediction_interval is None:
33
+ return
34
+ low, high = to_display_scale(
35
+ np.asarray(result.prediction_interval, dtype=np.float64),
36
+ display_scale=display_scale,
37
+ )
38
+ ax.hlines(y, low, high, colors="#d97706", linewidth=2.0, zorder=1)
39
+ ax.vlines(
40
+ [low, high],
41
+ y - 0.11,
42
+ y + 0.11,
43
+ colors="#d97706",
44
+ linewidth=1.4,
45
+ zorder=1,
46
+ )
47
+
48
+
49
+ def _draw_diamond(
50
+ ax: Axes,
51
+ result: MetaAnalysisResult,
52
+ *,
53
+ y: float,
54
+ color: str,
55
+ display_scale: str,
56
+ ) -> None:
57
+ from matplotlib.patches import Polygon
58
+
59
+ low, estimate, high = to_display_scale(
60
+ np.asarray([result.ci_low, result.estimate, result.ci_high]),
61
+ display_scale=display_scale,
62
+ )
63
+ ax.add_patch(
64
+ Polygon(
65
+ [
66
+ (low, y),
67
+ (estimate, y + 0.23),
68
+ (high, y),
69
+ (estimate, y - 0.23),
70
+ ],
71
+ closed=True,
72
+ facecolor=color,
73
+ edgecolor=color,
74
+ zorder=3,
75
+ )
76
+ )
77
+
78
+
79
+ def _study_coordinates(
80
+ result: MetaAnalysisResult,
81
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]:
82
+ studies = result.study_results
83
+ included = studies.loc[studies["included"]]
84
+ effect = included["effect"].to_numpy(dtype=np.float64, copy=True)
85
+ variance = included["variance"].to_numpy(dtype=np.float64, copy=True)
86
+ critical_value = float(norm.ppf(0.5 + result.method.confidence_level / 2.0))
87
+ margin = critical_value * np.sqrt(variance)
88
+ return (
89
+ to_display_scale(effect - margin, display_scale=result.display_scale),
90
+ to_display_scale(effect, display_scale=result.display_scale),
91
+ to_display_scale(effect + margin, display_scale=result.display_scale),
92
+ )
93
+
94
+
95
+ def subgroup_forest_plot(
96
+ result: SubgroupMetaAnalysisResult,
97
+ *,
98
+ ax: Axes | None = None,
99
+ effect_label: str | None = None,
100
+ show_prediction_interval: bool = True,
101
+ show_weights: bool = True,
102
+ null_value: float | None = None,
103
+ log_scale: bool | None = None,
104
+ ) -> Axes:
105
+ """Draw studies, subgroup summaries, and the overall pooled result."""
106
+
107
+ try:
108
+ import matplotlib.pyplot as plt
109
+ except ImportError as error: # pragma: no cover - tested without plot extra
110
+ raise ImportError(
111
+ "Subgroup forest plots require Matplotlib; install PyMetaAnalysis[plot]."
112
+ ) from error
113
+
114
+ overall = result.overall
115
+ use_log_scale = overall.display_scale == "exp" if log_scale is None else log_scale
116
+ resolved_null = (
117
+ (1.0 if overall.display_scale == "exp" else 0.0)
118
+ if null_value is None
119
+ else float(null_value)
120
+ )
121
+ if not np.isfinite(resolved_null) or (use_log_scale and resolved_null <= 0.0):
122
+ raise ValueError(
123
+ "null_value must be finite and strictly positive on a logarithmic axis."
124
+ )
125
+
126
+ included_count = sum(group.k for group in result.groups.values())
127
+ row_units = included_count + 2 * len(result.groups) + 1
128
+ created_axes = ax is None
129
+ if ax is None:
130
+ height = max(5.0, 0.42 * row_units + 1.5)
131
+ _, ax = plt.subplots(figsize=(9.0, height))
132
+ if use_log_scale:
133
+ ax.set_xscale("log")
134
+ configure_log_axis(ax)
135
+
136
+ overall_studies = overall.study_results.set_index("row_id")
137
+ cursor = float(row_units - 1)
138
+ tick_positions: list[float] = []
139
+ tick_labels: list[str] = []
140
+ tick_kinds: list[str] = []
141
+ study_weight_rows: list[tuple[float, float]] = []
142
+
143
+ ax.axvline(
144
+ resolved_null,
145
+ color="#777777",
146
+ linestyle="--",
147
+ linewidth=1.0,
148
+ zorder=0,
149
+ )
150
+
151
+ for label, group in result.groups.items():
152
+ header_y = cursor
153
+ tick_positions.append(header_y)
154
+ tick_labels.append(str(label))
155
+ tick_kinds.append("header")
156
+ cursor -= 1.0
157
+
158
+ studies = group.study_results
159
+ included = studies.loc[studies["included"]].reset_index(drop=True)
160
+ low, estimate, high = _study_coordinates(group)
161
+ y_studies = np.arange(
162
+ cursor,
163
+ cursor - len(included),
164
+ -1.0,
165
+ dtype=np.float64,
166
+ )
167
+ row_ids = included["row_id"].to_numpy(dtype=np.int64, copy=True)
168
+ weights = overall_studies.loc[row_ids, "normalized_weight"].to_numpy(
169
+ dtype=np.float64, copy=True
170
+ )
171
+ ax.hlines(
172
+ y_studies,
173
+ low,
174
+ high,
175
+ colors="#4c4c4c",
176
+ linewidth=1.2,
177
+ zorder=1,
178
+ )
179
+ ax.scatter(
180
+ estimate,
181
+ y_studies,
182
+ s=marker_areas(weights),
183
+ marker="s",
184
+ color="#2f6f9f",
185
+ edgecolors="white",
186
+ linewidths=0.6,
187
+ zorder=2,
188
+ )
189
+ for y_value, study, weight in zip(
190
+ y_studies,
191
+ included["study"],
192
+ weights,
193
+ strict=True,
194
+ ):
195
+ tick_positions.append(float(y_value))
196
+ tick_labels.append(f" {study}")
197
+ tick_kinds.append("study")
198
+ study_weight_rows.append((float(y_value), float(weight)))
199
+ cursor -= float(len(included))
200
+
201
+ pooled_y = cursor
202
+ if show_prediction_interval:
203
+ _draw_interval(
204
+ ax,
205
+ group,
206
+ y=pooled_y,
207
+ display_scale=overall.display_scale,
208
+ )
209
+ _draw_diamond(
210
+ ax,
211
+ group,
212
+ y=pooled_y,
213
+ color="#4f7f5f",
214
+ display_scale=overall.display_scale,
215
+ )
216
+ tick_positions.append(pooled_y)
217
+ tick_labels.append(f" {label} subtotal")
218
+ tick_kinds.append("subtotal")
219
+ cursor -= 1.0
220
+
221
+ overall_y = cursor
222
+ if show_prediction_interval:
223
+ _draw_interval(
224
+ ax,
225
+ overall,
226
+ y=overall_y,
227
+ display_scale=overall.display_scale,
228
+ )
229
+ _draw_diamond(
230
+ ax,
231
+ overall,
232
+ y=overall_y,
233
+ color="#1f4e79",
234
+ display_scale=overall.display_scale,
235
+ )
236
+ tick_positions.append(overall_y)
237
+ tick_labels.append("Overall")
238
+ tick_kinds.append("overall")
239
+
240
+ ax.set_yticks(tick_positions, labels=tick_labels)
241
+ for tick, kind in zip(ax.get_yticklabels(), tick_kinds, strict=True):
242
+ if kind in {"header", "subtotal", "overall"}:
243
+ tick.set_fontweight("bold")
244
+ ax.set_ylim(overall_y - 0.75, row_units - 0.25)
245
+ ax.set_xlabel(effect_label or default_effect_label(overall))
246
+ ax.tick_params(axis="y", length=0)
247
+ ax.grid(axis="x", color="#dddddd", linewidth=0.7, alpha=0.8)
248
+ ax.set_axisbelow(True)
249
+ for side in ("top", "right", "left"):
250
+ ax.spines[side].set_visible(False)
251
+ ax.margins(x=0.08)
252
+
253
+ if show_weights:
254
+ transform = ax.get_yaxis_transform()
255
+ ax.text(
256
+ 1.02,
257
+ row_units - 0.5,
258
+ "Weight",
259
+ transform=transform,
260
+ ha="left",
261
+ va="center",
262
+ fontweight="bold",
263
+ clip_on=False,
264
+ )
265
+ for weight_y, weight in study_weight_rows:
266
+ ax.text(
267
+ 1.02,
268
+ weight_y,
269
+ f"{100.0 * weight:.1f}%",
270
+ transform=transform,
271
+ ha="left",
272
+ va="center",
273
+ clip_on=False,
274
+ )
275
+
276
+ test_text = (
277
+ "Test for subgroup differences: "
278
+ f"Q({result.q_between_df})={result.q_between:.3g}, "
279
+ f"p={result.q_between_pvalue:.3g}"
280
+ )
281
+ ax.text(0.0, -0.09, test_text, transform=ax.transAxes, ha="left", va="top")
282
+ if created_axes:
283
+ ax.figure.subplots_adjust(left=0.29, right=0.82, bottom=0.17)
284
+ return ax