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,169 @@
|
|
|
1
|
+
"""Common- and random-effects inverse-variance estimation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from numpy.typing import NDArray
|
|
9
|
+
from scipy.stats import norm, t
|
|
10
|
+
|
|
11
|
+
from ..exceptions import InsufficientStudiesError, UnsupportedMethodError
|
|
12
|
+
from ..heterogeneity import weighted_mean
|
|
13
|
+
from .tau2 import Tau2Estimate, estimate_tau2
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class InverseVarianceFit:
|
|
18
|
+
"""Numerical outputs from an inverse-variance model."""
|
|
19
|
+
|
|
20
|
+
estimate: float
|
|
21
|
+
standard_error: float
|
|
22
|
+
ci_low: float
|
|
23
|
+
ci_high: float
|
|
24
|
+
prediction_interval: tuple[float, float] | None
|
|
25
|
+
weights: NDArray[np.float64]
|
|
26
|
+
normalized_weights: NDArray[np.float64]
|
|
27
|
+
tau2: Tau2Estimate | None
|
|
28
|
+
warnings: tuple[str, ...]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _confidence_interval(
|
|
32
|
+
*,
|
|
33
|
+
effect: NDArray[np.float64],
|
|
34
|
+
weights: NDArray[np.float64],
|
|
35
|
+
estimate: float,
|
|
36
|
+
classic_variance: float,
|
|
37
|
+
ci_method: str,
|
|
38
|
+
confidence_level: float,
|
|
39
|
+
) -> tuple[float, float, float, tuple[str, ...]]:
|
|
40
|
+
alpha = 1.0 - confidence_level
|
|
41
|
+
warnings: list[str] = []
|
|
42
|
+
|
|
43
|
+
if ci_method == "normal":
|
|
44
|
+
variance = classic_variance
|
|
45
|
+
critical_value = float(norm.ppf(1.0 - alpha / 2.0))
|
|
46
|
+
else:
|
|
47
|
+
df = len(effect) - 1
|
|
48
|
+
if bool(np.all(effect == effect[0])):
|
|
49
|
+
# Weighted means can differ from an identical input value by one ULP,
|
|
50
|
+
# depending on the platform's floating-point reduction. The residual
|
|
51
|
+
# variation is mathematically zero in this case, so preserve that
|
|
52
|
+
# invariant explicitly instead of squaring the rounding error.
|
|
53
|
+
scale = 0.0
|
|
54
|
+
else:
|
|
55
|
+
residual = effect - estimate
|
|
56
|
+
scale = float(np.dot(weights, residual * residual) / df)
|
|
57
|
+
hk_variance = scale / float(np.sum(weights))
|
|
58
|
+
if ci_method == "hartung_knapp_adhoc":
|
|
59
|
+
variance = max(classic_variance, hk_variance)
|
|
60
|
+
else:
|
|
61
|
+
variance = hk_variance
|
|
62
|
+
if hk_variance < classic_variance:
|
|
63
|
+
warnings.append(
|
|
64
|
+
"Hartung-Knapp produced a variance below the classic variance; "
|
|
65
|
+
"use ci_method='hartung_knapp_adhoc' for lower-bound protection."
|
|
66
|
+
)
|
|
67
|
+
if variance == 0.0:
|
|
68
|
+
warnings.append(
|
|
69
|
+
"Hartung-Knapp variance is zero because the included effects have "
|
|
70
|
+
"no weighted residual variation."
|
|
71
|
+
)
|
|
72
|
+
critical_value = float(t.ppf(1.0 - alpha / 2.0, df=df))
|
|
73
|
+
|
|
74
|
+
standard_error = float(np.sqrt(variance))
|
|
75
|
+
margin = critical_value * standard_error
|
|
76
|
+
return estimate - margin, estimate + margin, standard_error, tuple(warnings)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def fit_inverse_variance(
|
|
80
|
+
effect: NDArray[np.float64],
|
|
81
|
+
variance: NDArray[np.float64],
|
|
82
|
+
*,
|
|
83
|
+
model: str,
|
|
84
|
+
tau2_method: str,
|
|
85
|
+
ci_method: str,
|
|
86
|
+
confidence_level: float,
|
|
87
|
+
atol: float,
|
|
88
|
+
max_iter: int,
|
|
89
|
+
) -> InverseVarianceFit:
|
|
90
|
+
"""Fit a common- or random-effects inverse-variance model."""
|
|
91
|
+
|
|
92
|
+
if model == "random" and len(effect) < 2:
|
|
93
|
+
raise InsufficientStudiesError(
|
|
94
|
+
"A random-effects model requires at least two included studies."
|
|
95
|
+
)
|
|
96
|
+
if ci_method not in {"normal", "hartung_knapp", "hartung_knapp_adhoc"}:
|
|
97
|
+
raise UnsupportedMethodError(
|
|
98
|
+
"ci_method must be 'normal', 'hartung_knapp', or 'hartung_knapp_adhoc'."
|
|
99
|
+
)
|
|
100
|
+
if model == "common" and ci_method != "normal":
|
|
101
|
+
raise UnsupportedMethodError(
|
|
102
|
+
"Hartung-Knapp intervals are only supported for random-effects models."
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
tau2: Tau2Estimate | None
|
|
106
|
+
if model == "common":
|
|
107
|
+
tau2 = None
|
|
108
|
+
tau2_value = 0.0
|
|
109
|
+
else:
|
|
110
|
+
tau2 = estimate_tau2(
|
|
111
|
+
effect,
|
|
112
|
+
variance,
|
|
113
|
+
method=tau2_method,
|
|
114
|
+
atol=atol,
|
|
115
|
+
max_iter=max_iter,
|
|
116
|
+
)
|
|
117
|
+
tau2_value = tau2.value
|
|
118
|
+
|
|
119
|
+
denominator = variance + tau2_value
|
|
120
|
+
weights = 1.0 / denominator
|
|
121
|
+
variance_scale = float(np.min(denominator))
|
|
122
|
+
relative_weights = variance_scale / denominator
|
|
123
|
+
relative_weight_sum = float(np.sum(relative_weights))
|
|
124
|
+
normalized_weights = relative_weights / relative_weight_sum
|
|
125
|
+
estimate = weighted_mean(effect, relative_weights)
|
|
126
|
+
classic_variance = variance_scale / relative_weight_sum
|
|
127
|
+
ci_low, ci_high, standard_error, interval_warnings = _confidence_interval(
|
|
128
|
+
effect=effect,
|
|
129
|
+
weights=normalized_weights,
|
|
130
|
+
estimate=estimate,
|
|
131
|
+
classic_variance=classic_variance,
|
|
132
|
+
ci_method=ci_method,
|
|
133
|
+
confidence_level=confidence_level,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
warnings = list(interval_warnings)
|
|
137
|
+
prediction_interval: tuple[float, float] | None = None
|
|
138
|
+
if model == "random":
|
|
139
|
+
if len(effect) >= 3:
|
|
140
|
+
prediction_df = len(effect) - 2
|
|
141
|
+
critical_value = float(
|
|
142
|
+
t.ppf(0.5 + confidence_level / 2.0, df=prediction_df)
|
|
143
|
+
)
|
|
144
|
+
# HTS uses the classic variance of the pooled mean. This remains
|
|
145
|
+
# independent of any Hartung-Knapp method used for the mean CI.
|
|
146
|
+
prediction_se = float(np.sqrt(tau2_value + classic_variance))
|
|
147
|
+
margin = critical_value * prediction_se
|
|
148
|
+
prediction_interval = (estimate - margin, estimate + margin)
|
|
149
|
+
if len(effect) < 5:
|
|
150
|
+
warnings.append(
|
|
151
|
+
"Prediction intervals are especially uncertain with fewer "
|
|
152
|
+
"than five included studies."
|
|
153
|
+
)
|
|
154
|
+
else:
|
|
155
|
+
warnings.append(
|
|
156
|
+
"A prediction interval requires at least three included studies."
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return InverseVarianceFit(
|
|
160
|
+
estimate=estimate,
|
|
161
|
+
standard_error=standard_error,
|
|
162
|
+
ci_low=ci_low,
|
|
163
|
+
ci_high=ci_high,
|
|
164
|
+
prediction_interval=prediction_interval,
|
|
165
|
+
weights=weights,
|
|
166
|
+
normalized_weights=normalized_weights,
|
|
167
|
+
tau2=tau2,
|
|
168
|
+
warnings=tuple(warnings),
|
|
169
|
+
)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Mantel-Haenszel common-effect estimators for binary outcomes.
|
|
2
|
+
|
|
3
|
+
The pooled estimates and Greenland-Robins variance equations follow the
|
|
4
|
+
publicly documented Review Manager 5 statistical algorithms.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from numpy.typing import NDArray
|
|
13
|
+
from scipy.stats import norm
|
|
14
|
+
|
|
15
|
+
from ..exceptions import InvalidStudyDataError, UnsupportedMethodError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class MantelHaenszelFit:
|
|
20
|
+
"""Numerical outputs from a Mantel-Haenszel common-effect model."""
|
|
21
|
+
|
|
22
|
+
estimate: float
|
|
23
|
+
standard_error: float
|
|
24
|
+
ci_low: float
|
|
25
|
+
ci_high: float
|
|
26
|
+
weights: NDArray[np.float64]
|
|
27
|
+
normalized_weights: NDArray[np.float64]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def fit_mantel_haenszel(
|
|
31
|
+
a: NDArray[np.float64],
|
|
32
|
+
b: NDArray[np.float64],
|
|
33
|
+
c: NDArray[np.float64],
|
|
34
|
+
d: NDArray[np.float64],
|
|
35
|
+
*,
|
|
36
|
+
measure: str,
|
|
37
|
+
confidence_level: float,
|
|
38
|
+
) -> MantelHaenszelFit:
|
|
39
|
+
"""Fit a Mantel-Haenszel common-effect log OR or log RR."""
|
|
40
|
+
|
|
41
|
+
normalized_measure = measure.upper()
|
|
42
|
+
if normalized_measure not in {"OR", "RR"}:
|
|
43
|
+
raise UnsupportedMethodError(
|
|
44
|
+
"Mantel-Haenszel currently supports measure='OR' or measure='RR'."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
total = a + b + c + d
|
|
48
|
+
n1 = a + b
|
|
49
|
+
n2 = c + d
|
|
50
|
+
if normalized_measure == "OR":
|
|
51
|
+
r = float(np.sum(a * d / total))
|
|
52
|
+
s = float(np.sum(b * c / total))
|
|
53
|
+
if r <= 0.0 or s <= 0.0:
|
|
54
|
+
raise InvalidStudyDataError(
|
|
55
|
+
"The exact Mantel-Haenszel OR is undefined because its pooled "
|
|
56
|
+
"cross-product is zero; set a positive mh_continuity_correction."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
e = float(np.sum((a + d) * a * d / total**2))
|
|
60
|
+
f = float(np.sum((a + d) * b * c / total**2))
|
|
61
|
+
g = float(np.sum((b + c) * a * d / total**2))
|
|
62
|
+
h = float(np.sum((b + c) * b * c / total**2))
|
|
63
|
+
pooled = r / s
|
|
64
|
+
pooled_variance = 0.5 * (e / r**2 + (f + g) / (r * s) + h / s**2)
|
|
65
|
+
weights = b * c / total
|
|
66
|
+
else:
|
|
67
|
+
r = float(np.sum(a * n2 / total))
|
|
68
|
+
s = float(np.sum(c * n1 / total))
|
|
69
|
+
if r <= 0.0 or s <= 0.0:
|
|
70
|
+
raise InvalidStudyDataError(
|
|
71
|
+
"The exact Mantel-Haenszel RR is undefined because the pooled "
|
|
72
|
+
"event total is zero; set a positive mh_continuity_correction."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
p = float(np.sum((n1 * n2 * (a + c) - a * c * total) / total**2))
|
|
76
|
+
pooled = r / s
|
|
77
|
+
pooled_variance = p / (r * s)
|
|
78
|
+
weights = c * n1 / total
|
|
79
|
+
|
|
80
|
+
if not np.isfinite(pooled_variance) or pooled_variance <= 0.0:
|
|
81
|
+
raise InvalidStudyDataError(
|
|
82
|
+
"Mantel-Haenszel produced a non-positive sampling variance."
|
|
83
|
+
)
|
|
84
|
+
weight_sum = float(np.sum(weights))
|
|
85
|
+
if weight_sum <= 0.0:
|
|
86
|
+
raise InvalidStudyDataError(
|
|
87
|
+
"Mantel-Haenszel study weights have a non-positive sum."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
estimate = float(np.log(pooled))
|
|
91
|
+
standard_error = float(np.sqrt(pooled_variance))
|
|
92
|
+
critical_value = float(norm.ppf(0.5 + float(confidence_level) / 2.0))
|
|
93
|
+
margin = critical_value * standard_error
|
|
94
|
+
return MantelHaenszelFit(
|
|
95
|
+
estimate=estimate,
|
|
96
|
+
standard_error=standard_error,
|
|
97
|
+
ci_low=estimate - margin,
|
|
98
|
+
ci_high=estimate + margin,
|
|
99
|
+
weights=weights,
|
|
100
|
+
normalized_weights=weights / weight_sum,
|
|
101
|
+
)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Between-study variance estimators."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from numpy.typing import NDArray
|
|
10
|
+
from scipy.optimize import brentq
|
|
11
|
+
|
|
12
|
+
from ..exceptions import ConvergenceError, UnsupportedMethodError
|
|
13
|
+
from ..heterogeneity import generalized_q, weighted_mean
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class Tau2Estimate:
|
|
18
|
+
"""A between-study variance estimate and convergence metadata."""
|
|
19
|
+
|
|
20
|
+
value: float
|
|
21
|
+
method: str
|
|
22
|
+
converged: bool
|
|
23
|
+
iterations: int
|
|
24
|
+
boundary: bool
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _find_upper_bound(
|
|
28
|
+
function: Callable[[float], float],
|
|
29
|
+
*,
|
|
30
|
+
initial: float,
|
|
31
|
+
max_expansions: int,
|
|
32
|
+
) -> tuple[float, int]:
|
|
33
|
+
upper = max(float(initial), np.finfo(np.float64).tiny)
|
|
34
|
+
for expansion in range(max_expansions + 1):
|
|
35
|
+
value = function(upper)
|
|
36
|
+
if np.isfinite(value) and value <= 0.0:
|
|
37
|
+
return upper, expansion
|
|
38
|
+
upper *= 4.0
|
|
39
|
+
if not np.isfinite(upper):
|
|
40
|
+
break
|
|
41
|
+
raise ConvergenceError("Could not bracket a finite tau-squared solution.")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _dersimonian_laird(
|
|
45
|
+
effect: NDArray[np.float64], variance: NDArray[np.float64]
|
|
46
|
+
) -> Tau2Estimate:
|
|
47
|
+
weights = 1.0 / variance
|
|
48
|
+
estimate = weighted_mean(effect, weights)
|
|
49
|
+
residual = effect - estimate
|
|
50
|
+
q = float(np.dot(weights, residual * residual))
|
|
51
|
+
df = len(effect) - 1
|
|
52
|
+
weight_sum = float(np.sum(weights))
|
|
53
|
+
c = weight_sum - float(np.dot(weights, weights)) / weight_sum
|
|
54
|
+
value = max(0.0, (q - df) / c)
|
|
55
|
+
return Tau2Estimate(
|
|
56
|
+
value=value,
|
|
57
|
+
method="DL",
|
|
58
|
+
converged=True,
|
|
59
|
+
iterations=0,
|
|
60
|
+
boundary=value == 0.0,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _paule_mandel(
|
|
65
|
+
effect: NDArray[np.float64],
|
|
66
|
+
variance: NDArray[np.float64],
|
|
67
|
+
*,
|
|
68
|
+
atol: float,
|
|
69
|
+
max_iter: int,
|
|
70
|
+
) -> Tau2Estimate:
|
|
71
|
+
df = len(effect) - 1
|
|
72
|
+
|
|
73
|
+
def equation(tau2: float) -> float:
|
|
74
|
+
return generalized_q(effect, variance, tau2) - df
|
|
75
|
+
|
|
76
|
+
at_zero = equation(0.0)
|
|
77
|
+
if at_zero <= 0.0:
|
|
78
|
+
return Tau2Estimate(0.0, "PM", True, 0, True)
|
|
79
|
+
|
|
80
|
+
initial = max(float(np.var(effect, ddof=1)), float(np.max(variance)))
|
|
81
|
+
upper, expansions = _find_upper_bound(
|
|
82
|
+
equation, initial=initial, max_expansions=max_iter
|
|
83
|
+
)
|
|
84
|
+
try:
|
|
85
|
+
root, result = brentq(
|
|
86
|
+
equation,
|
|
87
|
+
0.0,
|
|
88
|
+
upper,
|
|
89
|
+
xtol=atol,
|
|
90
|
+
rtol=max(atol, 4.0 * np.finfo(np.float64).eps),
|
|
91
|
+
maxiter=max_iter,
|
|
92
|
+
full_output=True,
|
|
93
|
+
disp=False,
|
|
94
|
+
)
|
|
95
|
+
except (RuntimeError, ValueError) as error:
|
|
96
|
+
raise ConvergenceError("Paule-Mandel tau-squared estimation failed.") from error
|
|
97
|
+
if not result.converged:
|
|
98
|
+
raise ConvergenceError("Paule-Mandel tau-squared estimation did not converge.")
|
|
99
|
+
return Tau2Estimate(
|
|
100
|
+
value=max(0.0, float(root)),
|
|
101
|
+
method="PM",
|
|
102
|
+
converged=True,
|
|
103
|
+
iterations=expansions + result.iterations,
|
|
104
|
+
boundary=root <= atol,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _reml_score(
|
|
109
|
+
effect: NDArray[np.float64], variance: NDArray[np.float64], tau2: float
|
|
110
|
+
) -> float:
|
|
111
|
+
weights = 1.0 / (variance + tau2)
|
|
112
|
+
estimate = weighted_mean(effect, weights)
|
|
113
|
+
residual = effect - estimate
|
|
114
|
+
weighted_square_residual = float(np.dot(weights * weights, residual * residual))
|
|
115
|
+
trace_p = float(np.sum(weights) - np.dot(weights, weights) / np.sum(weights))
|
|
116
|
+
return 0.5 * (weighted_square_residual - trace_p)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _restricted_maximum_likelihood(
|
|
120
|
+
effect: NDArray[np.float64],
|
|
121
|
+
variance: NDArray[np.float64],
|
|
122
|
+
*,
|
|
123
|
+
atol: float,
|
|
124
|
+
max_iter: int,
|
|
125
|
+
) -> Tau2Estimate:
|
|
126
|
+
def score(tau2: float) -> float:
|
|
127
|
+
return _reml_score(effect, variance, tau2)
|
|
128
|
+
|
|
129
|
+
at_zero = score(0.0)
|
|
130
|
+
if at_zero <= 0.0:
|
|
131
|
+
return Tau2Estimate(0.0, "REML", True, 0, True)
|
|
132
|
+
|
|
133
|
+
initial = max(float(np.var(effect, ddof=1)), float(np.max(variance)))
|
|
134
|
+
upper, expansions = _find_upper_bound(
|
|
135
|
+
score, initial=initial, max_expansions=max_iter
|
|
136
|
+
)
|
|
137
|
+
try:
|
|
138
|
+
root, result = brentq(
|
|
139
|
+
score,
|
|
140
|
+
0.0,
|
|
141
|
+
upper,
|
|
142
|
+
xtol=atol,
|
|
143
|
+
rtol=max(atol, 4.0 * np.finfo(np.float64).eps),
|
|
144
|
+
maxiter=max_iter,
|
|
145
|
+
full_output=True,
|
|
146
|
+
disp=False,
|
|
147
|
+
)
|
|
148
|
+
except (RuntimeError, ValueError) as error:
|
|
149
|
+
raise ConvergenceError("REML tau-squared estimation failed.") from error
|
|
150
|
+
if not result.converged:
|
|
151
|
+
raise ConvergenceError("REML tau-squared estimation did not converge.")
|
|
152
|
+
return Tau2Estimate(
|
|
153
|
+
value=max(0.0, float(root)),
|
|
154
|
+
method="REML",
|
|
155
|
+
converged=True,
|
|
156
|
+
iterations=expansions + result.iterations,
|
|
157
|
+
boundary=root <= atol,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def estimate_tau2(
|
|
162
|
+
effect: NDArray[np.float64],
|
|
163
|
+
variance: NDArray[np.float64],
|
|
164
|
+
*,
|
|
165
|
+
method: str,
|
|
166
|
+
atol: float = 1e-10,
|
|
167
|
+
max_iter: int = 1000,
|
|
168
|
+
) -> Tau2Estimate:
|
|
169
|
+
"""Estimate between-study variance using DL, PM, or REML."""
|
|
170
|
+
|
|
171
|
+
normalized_method = method.upper().replace("-", "_")
|
|
172
|
+
if normalized_method == "DL":
|
|
173
|
+
return _dersimonian_laird(effect, variance)
|
|
174
|
+
if normalized_method == "PM":
|
|
175
|
+
return _paule_mandel(effect, variance, atol=atol, max_iter=max_iter)
|
|
176
|
+
if normalized_method == "REML":
|
|
177
|
+
return _restricted_maximum_likelihood(
|
|
178
|
+
effect, variance, atol=atol, max_iter=max_iter
|
|
179
|
+
)
|
|
180
|
+
raise UnsupportedMethodError(
|
|
181
|
+
f"Unsupported tau2_method={method!r}; expected 'DL', 'PM', or 'REML'."
|
|
182
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Domain-specific exceptions raised by :mod:`meta_analyze`."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MetaAnalysisError(Exception):
|
|
5
|
+
"""Base class for all library-specific errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InvalidStudyDataError(MetaAnalysisError, ValueError):
|
|
9
|
+
"""Raised when study data cannot be interpreted safely."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InsufficientStudiesError(MetaAnalysisError, ValueError):
|
|
13
|
+
"""Raised when a requested method needs more included studies."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConvergenceError(MetaAnalysisError, RuntimeError):
|
|
17
|
+
"""Raised when an iterative estimator fails to converge."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UnsupportedMethodError(MetaAnalysisError, ValueError):
|
|
21
|
+
"""Raised when a model or estimator name is unsupported."""
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Heterogeneity statistics for univariate meta-analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from numpy.typing import NDArray
|
|
7
|
+
from scipy.stats import chi2
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def weighted_mean(effect: NDArray[np.float64], weights: NDArray[np.float64]) -> float:
|
|
11
|
+
"""Return a weighted mean without overflowing the raw weight sum."""
|
|
12
|
+
|
|
13
|
+
largest = float(np.max(weights))
|
|
14
|
+
if not np.isfinite(largest) or largest <= 0.0:
|
|
15
|
+
raise ValueError("Weights must contain a finite, strictly positive value.")
|
|
16
|
+
scaled = weights / largest
|
|
17
|
+
scaled_sum = float(np.sum(scaled))
|
|
18
|
+
if not np.isfinite(scaled_sum) or scaled_sum <= 0.0:
|
|
19
|
+
raise ValueError("Weights must have a finite, strictly positive sum.")
|
|
20
|
+
if bool(np.all(effect == effect[0])):
|
|
21
|
+
return float(effect[0])
|
|
22
|
+
return float(np.dot(scaled, effect) / scaled_sum)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def generalized_q(
|
|
26
|
+
effect: NDArray[np.float64], variance: NDArray[np.float64], tau2: float
|
|
27
|
+
) -> float:
|
|
28
|
+
"""Return the weighted residual Q statistic at a given tau-squared."""
|
|
29
|
+
|
|
30
|
+
weights = 1.0 / (variance + tau2)
|
|
31
|
+
estimate = weighted_mean(effect, weights)
|
|
32
|
+
residual = effect - estimate
|
|
33
|
+
return float(np.dot(weights, residual * residual))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def classical_heterogeneity(
|
|
37
|
+
effect: NDArray[np.float64], variance: NDArray[np.float64]
|
|
38
|
+
) -> tuple[float, int, float, float, float]:
|
|
39
|
+
"""Return Q, degrees of freedom, p-value, I-squared, and H-squared."""
|
|
40
|
+
|
|
41
|
+
k = len(effect)
|
|
42
|
+
if k == 1:
|
|
43
|
+
return 0.0, 0, float("nan"), float("nan"), float("nan")
|
|
44
|
+
|
|
45
|
+
weights = 1.0 / variance
|
|
46
|
+
estimate = weighted_mean(effect, weights)
|
|
47
|
+
return heterogeneity_at_estimate(effect, variance, estimate)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def heterogeneity_at_estimate(
|
|
51
|
+
effect: NDArray[np.float64],
|
|
52
|
+
variance: NDArray[np.float64],
|
|
53
|
+
estimate: float,
|
|
54
|
+
) -> tuple[float, int, float, float, float]:
|
|
55
|
+
"""Return heterogeneity statistics around an explicitly pooled estimate."""
|
|
56
|
+
|
|
57
|
+
k = len(effect)
|
|
58
|
+
if k == 1:
|
|
59
|
+
return 0.0, 0, float("nan"), float("nan"), float("nan")
|
|
60
|
+
|
|
61
|
+
weights = 1.0 / variance
|
|
62
|
+
residual = effect - estimate
|
|
63
|
+
q = float(np.dot(weights, residual * residual))
|
|
64
|
+
df = k - 1
|
|
65
|
+
pvalue = float(chi2.sf(q, df))
|
|
66
|
+
i2 = 0.0 if q <= 0.0 else max(0.0, (q - df) / q)
|
|
67
|
+
h2 = q / df
|
|
68
|
+
return q, df, pvalue, i2, h2
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def tau2_inconsistency(
|
|
72
|
+
variance: NDArray[np.float64], tau2: float
|
|
73
|
+
) -> tuple[float, float]:
|
|
74
|
+
"""Return tau-squared-based I-squared and H-squared.
|
|
75
|
+
|
|
76
|
+
The typical within-study variance is ``(k - 1) / C``, where ``C`` is
|
|
77
|
+
calculated from common-effect inverse-variance weights. Scaled weights
|
|
78
|
+
keep the calculation stable when sampling variances are very small.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
k = len(variance)
|
|
82
|
+
if k == 1:
|
|
83
|
+
return float("nan"), float("nan")
|
|
84
|
+
if tau2 == 0.0:
|
|
85
|
+
return 0.0, 1.0
|
|
86
|
+
|
|
87
|
+
variance_scale = float(np.min(variance))
|
|
88
|
+
relative_weights = variance_scale / variance
|
|
89
|
+
weight_sum = float(np.sum(relative_weights))
|
|
90
|
+
c_scaled = (
|
|
91
|
+
weight_sum - float(np.dot(relative_weights, relative_weights)) / weight_sum
|
|
92
|
+
)
|
|
93
|
+
if not np.isfinite(c_scaled) or c_scaled <= 0.0:
|
|
94
|
+
return float("nan"), float("nan")
|
|
95
|
+
|
|
96
|
+
typical_variance = (k - 1) * variance_scale / c_scaled
|
|
97
|
+
i2 = 1.0 / (1.0 + typical_variance / tau2)
|
|
98
|
+
h2 = 1.0 + tau2 / typical_variance
|
|
99
|
+
return float(i2), float(h2)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Shared display-scale helpers for optional plotting backends."""
|
|
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
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from matplotlib.axes import Axes
|
|
12
|
+
|
|
13
|
+
from ..results import MetaAnalysisResult
|
|
14
|
+
else:
|
|
15
|
+
Axes = Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def to_display_scale(
|
|
19
|
+
values: NDArray[np.float64], *, display_scale: str
|
|
20
|
+
) -> NDArray[np.float64]:
|
|
21
|
+
"""Transform model-scale values to a result's display scale."""
|
|
22
|
+
|
|
23
|
+
if display_scale == "identity":
|
|
24
|
+
displayed = values.copy()
|
|
25
|
+
elif display_scale == "exp":
|
|
26
|
+
with np.errstate(over="ignore"):
|
|
27
|
+
displayed = np.exp(values)
|
|
28
|
+
else:
|
|
29
|
+
raise ValueError(f"Unknown display scale {display_scale!r}.")
|
|
30
|
+
if not np.all(np.isfinite(displayed)):
|
|
31
|
+
raise ValueError(
|
|
32
|
+
"Plot values are non-finite after display-scale transformation."
|
|
33
|
+
)
|
|
34
|
+
return displayed
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def default_effect_label(result: MetaAnalysisResult) -> str:
|
|
38
|
+
"""Return a readable x-axis label for a fitted measure."""
|
|
39
|
+
|
|
40
|
+
labels = {
|
|
41
|
+
"OR": "Odds ratio",
|
|
42
|
+
"RR": "Risk ratio",
|
|
43
|
+
"RD": "Risk difference",
|
|
44
|
+
"MD": "Mean difference",
|
|
45
|
+
"SMD": "Standardized mean difference",
|
|
46
|
+
"GENERIC": "Effect",
|
|
47
|
+
}
|
|
48
|
+
return labels.get(result.measure, result.measure)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def configure_log_axis(ax: Axes) -> None:
|
|
52
|
+
"""Use readable ratio ticks on a logarithmic x-axis."""
|
|
53
|
+
|
|
54
|
+
from matplotlib.ticker import FuncFormatter, LogLocator, NullFormatter
|
|
55
|
+
|
|
56
|
+
ax.xaxis.set_major_locator(LogLocator(base=10.0, subs=(1.0, 2.0, 5.0)))
|
|
57
|
+
ax.xaxis.set_major_formatter(FuncFormatter(lambda value, _: f"{value:g}"))
|
|
58
|
+
ax.xaxis.set_minor_formatter(NullFormatter())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def marker_areas(weights: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
62
|
+
"""Scale positive relative weights to readable forest-plot marker areas."""
|
|
63
|
+
|
|
64
|
+
largest = float(np.max(weights))
|
|
65
|
+
if not np.isfinite(largest) or largest <= 0.0:
|
|
66
|
+
raise ValueError("Forest plot weights must be finite and strictly positive.")
|
|
67
|
+
return 24.0 + 176.0 * weights / largest
|