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,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,7 @@
1
+ """Optional plotting helpers for fitted meta-analysis results."""
2
+
3
+ from .forest import forest_plot
4
+ from .funnel import funnel_plot
5
+ from .subgroup_forest import subgroup_forest_plot
6
+
7
+ __all__ = ["forest_plot", "funnel_plot", "subgroup_forest_plot"]
@@ -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