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.
- meta_analyze/__init__.py +62 -0
- meta_analyze/_version.py +3 -0
- meta_analyze/api.py +330 -0
- meta_analyze/binary_api.py +539 -0
- meta_analyze/config.py +34 -0
- meta_analyze/continuous_api.py +353 -0
- meta_analyze/data.py +181 -0
- meta_analyze/effect_sizes/__init__.py +17 -0
- meta_analyze/effect_sizes/binary.py +412 -0
- meta_analyze/effect_sizes/continuous.py +271 -0
- meta_analyze/estimators/__init__.py +14 -0
- meta_analyze/estimators/inverse_variance.py +169 -0
- meta_analyze/estimators/mantel_haenszel.py +101 -0
- meta_analyze/estimators/tau2.py +182 -0
- meta_analyze/exceptions.py +21 -0
- meta_analyze/heterogeneity.py +99 -0
- meta_analyze/plotting/__init__.py +7 -0
- meta_analyze/plotting/_utils.py +67 -0
- meta_analyze/plotting/forest.py +193 -0
- meta_analyze/plotting/funnel.py +168 -0
- meta_analyze/plotting/subgroup_forest.py +284 -0
- meta_analyze/provenance.py +185 -0
- meta_analyze/py.typed +1 -0
- meta_analyze/reporting.py +433 -0
- meta_analyze/results.py +519 -0
- meta_analyze/sensitivity.py +546 -0
- meta_analyze/subgroups.py +165 -0
- pymetaanalysis-0.1.0.dist-info/METADATA +218 -0
- pymetaanalysis-0.1.0.dist-info/RECORD +31 -0
- pymetaanalysis-0.1.0.dist-info/WHEEL +4 -0
- pymetaanalysis-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|