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,412 @@
|
|
|
1
|
+
"""Effect sizes and validation for two-group binary outcomes.
|
|
2
|
+
|
|
3
|
+
OR, RR, and RD equations follow the publicly documented Review Manager 5
|
|
4
|
+
statistical algorithms by Deeks and Higgins (2010).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from numbers import Real
|
|
11
|
+
from typing import Literal, TypeAlias, cast
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import pandas as pd
|
|
15
|
+
from numpy.typing import NDArray
|
|
16
|
+
|
|
17
|
+
from ..data import (
|
|
18
|
+
ColumnOrArray,
|
|
19
|
+
MissingPolicy,
|
|
20
|
+
_resolve_vector,
|
|
21
|
+
_study_labels,
|
|
22
|
+
)
|
|
23
|
+
from ..exceptions import InvalidStudyDataError, UnsupportedMethodError
|
|
24
|
+
|
|
25
|
+
CorrectionScope: TypeAlias = Literal[
|
|
26
|
+
"only_zero_studies", "all_studies", "if_any_zero", "none"
|
|
27
|
+
]
|
|
28
|
+
RDZeroVariancePolicy: TypeAlias = Literal["correct", "exclude"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class BinaryStudies:
|
|
33
|
+
"""Validated raw counts and row-level inclusion metadata."""
|
|
34
|
+
|
|
35
|
+
row_id: NDArray[np.int64]
|
|
36
|
+
study: NDArray[np.object_]
|
|
37
|
+
event_treat: NDArray[np.float64]
|
|
38
|
+
n_treat: NDArray[np.float64]
|
|
39
|
+
event_control: NDArray[np.float64]
|
|
40
|
+
n_control: NDArray[np.float64]
|
|
41
|
+
included: NDArray[np.bool_]
|
|
42
|
+
exclusion_reason: NDArray[np.object_]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class BinaryEffectData:
|
|
47
|
+
"""Binary study effects on the model scale."""
|
|
48
|
+
|
|
49
|
+
studies: BinaryStudies
|
|
50
|
+
effect: NDArray[np.float64]
|
|
51
|
+
variance: NDArray[np.float64]
|
|
52
|
+
corrected: NDArray[np.bool_]
|
|
53
|
+
rd_zero_variance: NDArray[np.bool_]
|
|
54
|
+
measure: str
|
|
55
|
+
effect_scale: str
|
|
56
|
+
display_scale: str
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def included_effect(self) -> NDArray[np.float64]:
|
|
60
|
+
return self.effect[self.studies.included]
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def included_variance(self) -> NDArray[np.float64]:
|
|
64
|
+
return self.variance[self.studies.included]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def normalize_correction_scope(scope: str) -> CorrectionScope:
|
|
68
|
+
normalized = scope.lower().replace("-", "_")
|
|
69
|
+
aliases = {
|
|
70
|
+
"only0": "only_zero_studies",
|
|
71
|
+
"only_zero_cells": "only_zero_studies",
|
|
72
|
+
"all": "all_studies",
|
|
73
|
+
"if0all": "if_any_zero",
|
|
74
|
+
}
|
|
75
|
+
normalized = aliases.get(normalized, normalized)
|
|
76
|
+
allowed = {"only_zero_studies", "all_studies", "if_any_zero", "none"}
|
|
77
|
+
if normalized not in allowed:
|
|
78
|
+
raise UnsupportedMethodError(
|
|
79
|
+
"correction_scope must be 'only_zero_studies', 'all_studies', "
|
|
80
|
+
"'if_any_zero', or 'none'."
|
|
81
|
+
)
|
|
82
|
+
return cast(CorrectionScope, normalized)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def normalize_rd_zero_variance(policy: str) -> RDZeroVariancePolicy:
|
|
86
|
+
"""Normalize the policy for RD studies with zero raw sampling variance."""
|
|
87
|
+
|
|
88
|
+
if not isinstance(policy, str):
|
|
89
|
+
raise UnsupportedMethodError("rd_zero_variance must be 'correct' or 'exclude'.")
|
|
90
|
+
normalized = policy.lower().replace("-", "_")
|
|
91
|
+
if normalized not in {"correct", "exclude"}:
|
|
92
|
+
raise UnsupportedMethodError("rd_zero_variance must be 'correct' or 'exclude'.")
|
|
93
|
+
return cast(RDZeroVariancePolicy, normalized)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def validate_correction(value: float | None, *, name: str) -> float:
|
|
97
|
+
if value is None:
|
|
98
|
+
return 0.0
|
|
99
|
+
if isinstance(value, bool) or not isinstance(value, Real):
|
|
100
|
+
raise InvalidStudyDataError(f"{name} must be a non-negative number or None.")
|
|
101
|
+
numeric = float(value)
|
|
102
|
+
if not np.isfinite(numeric) or numeric < 0.0:
|
|
103
|
+
raise InvalidStudyDataError(f"{name} must be finite and non-negative.")
|
|
104
|
+
return numeric
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _missing_reason(names: list[str]) -> str:
|
|
108
|
+
return "missing " + ", ".join(names)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def normalize_binary_studies(
|
|
112
|
+
*,
|
|
113
|
+
data: pd.DataFrame | None,
|
|
114
|
+
event_treat: ColumnOrArray,
|
|
115
|
+
n_treat: ColumnOrArray,
|
|
116
|
+
event_control: ColumnOrArray,
|
|
117
|
+
n_control: ColumnOrArray,
|
|
118
|
+
study: ColumnOrArray | None,
|
|
119
|
+
missing: MissingPolicy,
|
|
120
|
+
) -> BinaryStudies:
|
|
121
|
+
"""Resolve and validate two-group binary study counts."""
|
|
122
|
+
|
|
123
|
+
if data is not None and not isinstance(data, pd.DataFrame):
|
|
124
|
+
raise InvalidStudyDataError("data must be a pandas DataFrame or None.")
|
|
125
|
+
if missing not in {"raise", "drop"}:
|
|
126
|
+
raise InvalidStudyDataError("missing must be either 'raise' or 'drop'.")
|
|
127
|
+
|
|
128
|
+
arguments = {
|
|
129
|
+
"event_treat": event_treat,
|
|
130
|
+
"n_treat": n_treat,
|
|
131
|
+
"event_control": event_control,
|
|
132
|
+
"n_control": n_control,
|
|
133
|
+
}
|
|
134
|
+
raw = {
|
|
135
|
+
name: _resolve_vector(value, data=data, name=name)
|
|
136
|
+
for name, value in arguments.items()
|
|
137
|
+
}
|
|
138
|
+
lengths = {len(values) for values in raw.values()}
|
|
139
|
+
if len(lengths) != 1:
|
|
140
|
+
detail = ", ".join(f"{name}={len(values)}" for name, values in raw.items())
|
|
141
|
+
raise InvalidStudyDataError(
|
|
142
|
+
f"Binary count inputs must have equal lengths; {detail}."
|
|
143
|
+
)
|
|
144
|
+
length = lengths.pop()
|
|
145
|
+
if data is not None and len(data) != length:
|
|
146
|
+
raise InvalidStudyDataError(
|
|
147
|
+
"Array-like binary inputs used with data must have exactly one value "
|
|
148
|
+
"per DataFrame row."
|
|
149
|
+
)
|
|
150
|
+
labels = _study_labels(study, data=data, length=length)
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
values = {
|
|
154
|
+
name: np.asarray(vector, dtype=np.float64) for name, vector in raw.items()
|
|
155
|
+
}
|
|
156
|
+
except (TypeError, ValueError) as error:
|
|
157
|
+
raise InvalidStudyDataError(
|
|
158
|
+
"Binary counts must contain numeric values."
|
|
159
|
+
) from error
|
|
160
|
+
|
|
161
|
+
missing_by_name = {name: pd.isna(vector) for name, vector in values.items()}
|
|
162
|
+
any_missing = np.logical_or.reduce(tuple(missing_by_name.values()))
|
|
163
|
+
if np.any(any_missing) and missing == "raise":
|
|
164
|
+
rows = np.flatnonzero(any_missing).tolist()
|
|
165
|
+
raise InvalidStudyDataError(
|
|
166
|
+
f"Missing binary counts at row positions {rows}; use missing='drop' "
|
|
167
|
+
"to exclude them explicitly."
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
for name, vector in values.items():
|
|
171
|
+
present = ~missing_by_name[name]
|
|
172
|
+
invalid_finite = present & ~np.isfinite(vector)
|
|
173
|
+
if np.any(invalid_finite):
|
|
174
|
+
rows = np.flatnonzero(invalid_finite).tolist()
|
|
175
|
+
raise InvalidStudyDataError(f"{name} must be finite; invalid rows: {rows}.")
|
|
176
|
+
noninteger = present & (vector != np.floor(vector))
|
|
177
|
+
if np.any(noninteger):
|
|
178
|
+
rows = np.flatnonzero(noninteger).tolist()
|
|
179
|
+
raise InvalidStudyDataError(
|
|
180
|
+
f"{name} must contain whole-number counts; invalid rows: {rows}."
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
active = ~any_missing
|
|
184
|
+
for total_name in ("n_treat", "n_control"):
|
|
185
|
+
invalid = active & (values[total_name] <= 0.0)
|
|
186
|
+
if np.any(invalid):
|
|
187
|
+
rows = np.flatnonzero(invalid).tolist()
|
|
188
|
+
raise InvalidStudyDataError(
|
|
189
|
+
f"{total_name} must be strictly positive; invalid rows: {rows}."
|
|
190
|
+
)
|
|
191
|
+
for event_name, total_name in (
|
|
192
|
+
("event_treat", "n_treat"),
|
|
193
|
+
("event_control", "n_control"),
|
|
194
|
+
):
|
|
195
|
+
invalid = active & (
|
|
196
|
+
(values[event_name] < 0.0) | (values[event_name] > values[total_name])
|
|
197
|
+
)
|
|
198
|
+
if np.any(invalid):
|
|
199
|
+
rows = np.flatnonzero(invalid).tolist()
|
|
200
|
+
raise InvalidStudyDataError(
|
|
201
|
+
f"{event_name} must be between 0 and {total_name}; "
|
|
202
|
+
f"invalid rows: {rows}."
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
reasons = np.full(length, None, dtype=object)
|
|
206
|
+
for index in np.flatnonzero(any_missing):
|
|
207
|
+
names = [name for name, mask in missing_by_name.items() if mask[index]]
|
|
208
|
+
reasons[index] = _missing_reason(names)
|
|
209
|
+
if not np.any(active):
|
|
210
|
+
raise InvalidStudyDataError(
|
|
211
|
+
"No studies remain after applying the missing-value policy."
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
return BinaryStudies(
|
|
215
|
+
row_id=np.arange(length, dtype=np.int64),
|
|
216
|
+
study=labels,
|
|
217
|
+
event_treat=values["event_treat"],
|
|
218
|
+
n_treat=values["n_treat"],
|
|
219
|
+
event_control=values["event_control"],
|
|
220
|
+
n_control=values["n_control"],
|
|
221
|
+
included=active,
|
|
222
|
+
exclusion_reason=reasons,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def correction_mask(
|
|
227
|
+
a: NDArray[np.float64],
|
|
228
|
+
b: NDArray[np.float64],
|
|
229
|
+
c: NDArray[np.float64],
|
|
230
|
+
d: NDArray[np.float64],
|
|
231
|
+
*,
|
|
232
|
+
included: NDArray[np.bool_],
|
|
233
|
+
scope: CorrectionScope,
|
|
234
|
+
) -> NDArray[np.bool_]:
|
|
235
|
+
zero_study: NDArray[np.bool_] = np.asarray(
|
|
236
|
+
(a == 0.0) | (b == 0.0) | (c == 0.0) | (d == 0.0),
|
|
237
|
+
dtype=np.bool_,
|
|
238
|
+
)
|
|
239
|
+
if scope == "only_zero_studies":
|
|
240
|
+
return included & zero_study
|
|
241
|
+
if scope == "all_studies":
|
|
242
|
+
return included.copy()
|
|
243
|
+
if scope == "if_any_zero":
|
|
244
|
+
return (
|
|
245
|
+
included.copy()
|
|
246
|
+
if np.any(included & zero_study)
|
|
247
|
+
else np.zeros_like(included)
|
|
248
|
+
)
|
|
249
|
+
return np.zeros_like(included)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def adjusted_tables(
|
|
253
|
+
studies: BinaryStudies,
|
|
254
|
+
*,
|
|
255
|
+
correction: float,
|
|
256
|
+
scope: CorrectionScope,
|
|
257
|
+
) -> tuple[
|
|
258
|
+
NDArray[np.float64],
|
|
259
|
+
NDArray[np.float64],
|
|
260
|
+
NDArray[np.float64],
|
|
261
|
+
NDArray[np.float64],
|
|
262
|
+
NDArray[np.bool_],
|
|
263
|
+
]:
|
|
264
|
+
a = studies.event_treat.copy()
|
|
265
|
+
b = studies.n_treat - studies.event_treat
|
|
266
|
+
c = studies.event_control.copy()
|
|
267
|
+
d = studies.n_control - studies.event_control
|
|
268
|
+
corrected = correction_mask(a, b, c, d, included=studies.included, scope=scope) & (
|
|
269
|
+
correction > 0.0
|
|
270
|
+
)
|
|
271
|
+
if np.any(corrected):
|
|
272
|
+
for cell in (a, b, c, d):
|
|
273
|
+
cell[corrected] += correction
|
|
274
|
+
return a, b, c, d, corrected
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def calculate_binary_effects(
|
|
278
|
+
studies: BinaryStudies,
|
|
279
|
+
*,
|
|
280
|
+
measure: str,
|
|
281
|
+
continuity_correction: float,
|
|
282
|
+
correction_scope: CorrectionScope,
|
|
283
|
+
rd_zero_variance: str = "correct",
|
|
284
|
+
) -> BinaryEffectData:
|
|
285
|
+
"""Calculate OR, RR, or RD effects and sampling variances."""
|
|
286
|
+
|
|
287
|
+
normalized_measure = measure.upper()
|
|
288
|
+
if normalized_measure not in {"OR", "RR", "RD"}:
|
|
289
|
+
raise UnsupportedMethodError("measure must be 'OR', 'RR', or 'RD'.")
|
|
290
|
+
rd_policy = normalize_rd_zero_variance(rd_zero_variance)
|
|
291
|
+
if normalized_measure != "RD" and rd_policy != "correct":
|
|
292
|
+
raise UnsupportedMethodError(
|
|
293
|
+
"rd_zero_variance is only configurable when measure='RD'."
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
included = studies.included.copy()
|
|
297
|
+
reasons = studies.exclusion_reason.copy()
|
|
298
|
+
rd_zero_variance_mask = np.zeros_like(included)
|
|
299
|
+
if normalized_measure == "RD":
|
|
300
|
+
raw_rd_variance = (
|
|
301
|
+
studies.event_treat
|
|
302
|
+
* (studies.n_treat - studies.event_treat)
|
|
303
|
+
/ studies.n_treat**3
|
|
304
|
+
+ studies.event_control
|
|
305
|
+
* (studies.n_control - studies.event_control)
|
|
306
|
+
/ studies.n_control**3
|
|
307
|
+
)
|
|
308
|
+
rd_zero_variance_mask = np.asarray(
|
|
309
|
+
included & (raw_rd_variance == 0.0), dtype=np.bool_
|
|
310
|
+
)
|
|
311
|
+
if rd_policy == "exclude":
|
|
312
|
+
included[rd_zero_variance_mask] = False
|
|
313
|
+
for index in np.flatnonzero(rd_zero_variance_mask):
|
|
314
|
+
reasons[index] = "zero uncorrected risk-difference variance"
|
|
315
|
+
|
|
316
|
+
if normalized_measure in {"OR", "RR"}:
|
|
317
|
+
double_zero = (studies.event_treat == 0.0) & (studies.event_control == 0.0)
|
|
318
|
+
double_all = (studies.event_treat == studies.n_treat) & (
|
|
319
|
+
studies.event_control == studies.n_control
|
|
320
|
+
)
|
|
321
|
+
uninformative = included & (double_zero | double_all)
|
|
322
|
+
included[uninformative] = False
|
|
323
|
+
for index in np.flatnonzero(uninformative):
|
|
324
|
+
reasons[index] = (
|
|
325
|
+
"no events in either group"
|
|
326
|
+
if double_zero[index]
|
|
327
|
+
else "all participants have events in both groups"
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
working_studies = BinaryStudies(
|
|
331
|
+
row_id=studies.row_id,
|
|
332
|
+
study=studies.study,
|
|
333
|
+
event_treat=studies.event_treat,
|
|
334
|
+
n_treat=studies.n_treat,
|
|
335
|
+
event_control=studies.event_control,
|
|
336
|
+
n_control=studies.n_control,
|
|
337
|
+
included=included,
|
|
338
|
+
exclusion_reason=reasons,
|
|
339
|
+
)
|
|
340
|
+
if not np.any(included):
|
|
341
|
+
raise InvalidStudyDataError(
|
|
342
|
+
f"No informative studies remain for measure={normalized_measure!r}."
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
a, b, c, d, corrected = adjusted_tables(
|
|
346
|
+
working_studies,
|
|
347
|
+
correction=continuity_correction,
|
|
348
|
+
scope=correction_scope,
|
|
349
|
+
)
|
|
350
|
+
zero_after_correction = included & (
|
|
351
|
+
(a == 0.0) | (b == 0.0) | (c == 0.0) | (d == 0.0)
|
|
352
|
+
)
|
|
353
|
+
if normalized_measure in {"OR", "RR"} and np.any(zero_after_correction):
|
|
354
|
+
rows = np.flatnonzero(zero_after_correction).tolist()
|
|
355
|
+
raise InvalidStudyDataError(
|
|
356
|
+
"OR/RR study effects are undefined with remaining zero cells at row "
|
|
357
|
+
f"positions {rows}; use a positive continuity_correction."
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
effect = np.full(len(included), np.nan, dtype=np.float64)
|
|
361
|
+
variance = np.full(len(included), np.nan, dtype=np.float64)
|
|
362
|
+
active = included
|
|
363
|
+
if normalized_measure == "OR":
|
|
364
|
+
effect[active] = np.log((a[active] * d[active]) / (b[active] * c[active]))
|
|
365
|
+
variance[active] = (
|
|
366
|
+
1.0 / a[active] + 1.0 / b[active] + 1.0 / c[active] + 1.0 / d[active]
|
|
367
|
+
)
|
|
368
|
+
effect_scale = "log"
|
|
369
|
+
display_scale = "exp"
|
|
370
|
+
elif normalized_measure == "RR":
|
|
371
|
+
n1 = a + b
|
|
372
|
+
n2 = c + d
|
|
373
|
+
effect[active] = np.log((a[active] / n1[active]) / (c[active] / n2[active]))
|
|
374
|
+
variance[active] = (
|
|
375
|
+
1.0 / a[active] - 1.0 / n1[active] + 1.0 / c[active] - 1.0 / n2[active]
|
|
376
|
+
)
|
|
377
|
+
effect_scale = "log"
|
|
378
|
+
display_scale = "exp"
|
|
379
|
+
else:
|
|
380
|
+
# RD remains on the uncorrected natural scale. Corrected counts are used
|
|
381
|
+
# only for its sampling variance when zero cells would make it degenerate.
|
|
382
|
+
effect[active] = (
|
|
383
|
+
studies.event_treat[active] / studies.n_treat[active]
|
|
384
|
+
- studies.event_control[active] / studies.n_control[active]
|
|
385
|
+
)
|
|
386
|
+
n1 = a + b
|
|
387
|
+
n2 = c + d
|
|
388
|
+
variance[active] = (
|
|
389
|
+
a[active] * b[active] / n1[active] ** 3
|
|
390
|
+
+ c[active] * d[active] / n2[active] ** 3
|
|
391
|
+
)
|
|
392
|
+
effect_scale = "identity"
|
|
393
|
+
display_scale = "identity"
|
|
394
|
+
|
|
395
|
+
invalid_variance = active & (~np.isfinite(variance) | (variance <= 0.0))
|
|
396
|
+
if np.any(invalid_variance):
|
|
397
|
+
rows = np.flatnonzero(invalid_variance).tolist()
|
|
398
|
+
raise InvalidStudyDataError(
|
|
399
|
+
f"Non-positive binary sampling variance at row positions {rows}; "
|
|
400
|
+
"use an appropriate positive continuity_correction."
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
return BinaryEffectData(
|
|
404
|
+
studies=working_studies,
|
|
405
|
+
effect=effect,
|
|
406
|
+
variance=variance,
|
|
407
|
+
corrected=corrected,
|
|
408
|
+
rd_zero_variance=rd_zero_variance_mask,
|
|
409
|
+
measure=normalized_measure,
|
|
410
|
+
effect_scale=effect_scale,
|
|
411
|
+
display_scale=display_scale,
|
|
412
|
+
)
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Effect sizes and validation for two independent continuous groups.
|
|
2
|
+
|
|
3
|
+
The standardized mean difference follows ``metafor::escalc(measure="SMD")``:
|
|
4
|
+
the group mean difference is divided by the pooled standard deviation, then
|
|
5
|
+
multiplied by the exact Hedges correction. Its default ``LS`` sampling
|
|
6
|
+
variance is the large-sample approximation from Hedges (1982).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import pandas as pd
|
|
15
|
+
from numpy.typing import NDArray
|
|
16
|
+
from scipy.special import gammaln
|
|
17
|
+
|
|
18
|
+
from ..data import ColumnOrArray, MissingPolicy, _resolve_vector, _study_labels
|
|
19
|
+
from ..exceptions import InvalidStudyDataError, UnsupportedMethodError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class ContinuousStudies:
|
|
24
|
+
"""Validated two-group summary statistics and inclusion metadata."""
|
|
25
|
+
|
|
26
|
+
row_id: NDArray[np.int64]
|
|
27
|
+
study: NDArray[np.object_]
|
|
28
|
+
mean_treat: NDArray[np.float64]
|
|
29
|
+
sd_treat: NDArray[np.float64]
|
|
30
|
+
n_treat: NDArray[np.float64]
|
|
31
|
+
mean_control: NDArray[np.float64]
|
|
32
|
+
sd_control: NDArray[np.float64]
|
|
33
|
+
n_control: NDArray[np.float64]
|
|
34
|
+
included: NDArray[np.bool_]
|
|
35
|
+
exclusion_reason: NDArray[np.object_]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class ContinuousEffectData:
|
|
40
|
+
"""Continuous study effects on their model scale."""
|
|
41
|
+
|
|
42
|
+
studies: ContinuousStudies
|
|
43
|
+
effect: NDArray[np.float64]
|
|
44
|
+
variance: NDArray[np.float64]
|
|
45
|
+
pooled_sd: NDArray[np.float64]
|
|
46
|
+
cohen_d: NDArray[np.float64]
|
|
47
|
+
correction_factor: NDArray[np.float64]
|
|
48
|
+
measure: str
|
|
49
|
+
effect_scale: str = "identity"
|
|
50
|
+
display_scale: str = "identity"
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def included_effect(self) -> NDArray[np.float64]:
|
|
54
|
+
return self.effect[self.studies.included]
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def included_variance(self) -> NDArray[np.float64]:
|
|
58
|
+
return self.variance[self.studies.included]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _missing_reason(names: list[str]) -> str:
|
|
62
|
+
return "missing " + ", ".join(names)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def normalize_continuous_studies(
|
|
66
|
+
*,
|
|
67
|
+
data: pd.DataFrame | None,
|
|
68
|
+
mean_treat: ColumnOrArray,
|
|
69
|
+
sd_treat: ColumnOrArray,
|
|
70
|
+
n_treat: ColumnOrArray,
|
|
71
|
+
mean_control: ColumnOrArray,
|
|
72
|
+
sd_control: ColumnOrArray,
|
|
73
|
+
n_control: ColumnOrArray,
|
|
74
|
+
study: ColumnOrArray | None,
|
|
75
|
+
missing: MissingPolicy,
|
|
76
|
+
) -> ContinuousStudies:
|
|
77
|
+
"""Resolve and validate two-group continuous summary statistics."""
|
|
78
|
+
|
|
79
|
+
if data is not None and not isinstance(data, pd.DataFrame):
|
|
80
|
+
raise InvalidStudyDataError("data must be a pandas DataFrame or None.")
|
|
81
|
+
if missing not in {"raise", "drop"}:
|
|
82
|
+
raise InvalidStudyDataError("missing must be either 'raise' or 'drop'.")
|
|
83
|
+
|
|
84
|
+
arguments = {
|
|
85
|
+
"mean_treat": mean_treat,
|
|
86
|
+
"sd_treat": sd_treat,
|
|
87
|
+
"n_treat": n_treat,
|
|
88
|
+
"mean_control": mean_control,
|
|
89
|
+
"sd_control": sd_control,
|
|
90
|
+
"n_control": n_control,
|
|
91
|
+
}
|
|
92
|
+
raw = {
|
|
93
|
+
name: _resolve_vector(value, data=data, name=name)
|
|
94
|
+
for name, value in arguments.items()
|
|
95
|
+
}
|
|
96
|
+
lengths = {len(values) for values in raw.values()}
|
|
97
|
+
if len(lengths) != 1:
|
|
98
|
+
detail = ", ".join(f"{name}={len(values)}" for name, values in raw.items())
|
|
99
|
+
raise InvalidStudyDataError(
|
|
100
|
+
f"Continuous inputs must have equal lengths; {detail}."
|
|
101
|
+
)
|
|
102
|
+
length = lengths.pop()
|
|
103
|
+
if data is not None and len(data) != length:
|
|
104
|
+
raise InvalidStudyDataError(
|
|
105
|
+
"Array-like continuous inputs used with data must have exactly one "
|
|
106
|
+
"value per DataFrame row."
|
|
107
|
+
)
|
|
108
|
+
labels = _study_labels(study, data=data, length=length)
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
values = {
|
|
112
|
+
name: np.asarray(vector, dtype=np.float64) for name, vector in raw.items()
|
|
113
|
+
}
|
|
114
|
+
except (TypeError, ValueError) as error:
|
|
115
|
+
raise InvalidStudyDataError(
|
|
116
|
+
"Continuous inputs must contain numeric values."
|
|
117
|
+
) from error
|
|
118
|
+
|
|
119
|
+
missing_by_name = {name: pd.isna(vector) for name, vector in values.items()}
|
|
120
|
+
any_missing = np.logical_or.reduce(tuple(missing_by_name.values()))
|
|
121
|
+
if np.any(any_missing) and missing == "raise":
|
|
122
|
+
rows = np.flatnonzero(any_missing).tolist()
|
|
123
|
+
raise InvalidStudyDataError(
|
|
124
|
+
f"Missing continuous inputs at row positions {rows}; use "
|
|
125
|
+
"missing='drop' to exclude them explicitly."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
for name, vector in values.items():
|
|
129
|
+
present = ~missing_by_name[name]
|
|
130
|
+
invalid = present & ~np.isfinite(vector)
|
|
131
|
+
if np.any(invalid):
|
|
132
|
+
rows = np.flatnonzero(invalid).tolist()
|
|
133
|
+
raise InvalidStudyDataError(f"{name} must be finite; invalid rows: {rows}.")
|
|
134
|
+
|
|
135
|
+
active = ~any_missing
|
|
136
|
+
for name in ("n_treat", "n_control"):
|
|
137
|
+
vector = values[name]
|
|
138
|
+
noninteger = active & (vector != np.floor(vector))
|
|
139
|
+
if np.any(noninteger):
|
|
140
|
+
rows = np.flatnonzero(noninteger).tolist()
|
|
141
|
+
raise InvalidStudyDataError(
|
|
142
|
+
f"{name} must contain whole-number sample sizes; invalid rows: {rows}."
|
|
143
|
+
)
|
|
144
|
+
too_small = active & (vector < 2.0)
|
|
145
|
+
if np.any(too_small):
|
|
146
|
+
rows = np.flatnonzero(too_small).tolist()
|
|
147
|
+
raise InvalidStudyDataError(
|
|
148
|
+
f"{name} must be at least 2 when a sample SD is supplied; "
|
|
149
|
+
f"invalid rows: {rows}."
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
for name in ("sd_treat", "sd_control"):
|
|
153
|
+
invalid = active & (values[name] < 0.0)
|
|
154
|
+
if np.any(invalid):
|
|
155
|
+
rows = np.flatnonzero(invalid).tolist()
|
|
156
|
+
raise InvalidStudyDataError(
|
|
157
|
+
f"{name} must be non-negative; invalid rows: {rows}."
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
reasons = np.full(length, None, dtype=object)
|
|
161
|
+
for index in np.flatnonzero(any_missing):
|
|
162
|
+
names = [name for name, mask in missing_by_name.items() if mask[index]]
|
|
163
|
+
reasons[index] = _missing_reason(names)
|
|
164
|
+
if not np.any(active):
|
|
165
|
+
raise InvalidStudyDataError(
|
|
166
|
+
"No studies remain after applying the missing-value policy."
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return ContinuousStudies(
|
|
170
|
+
row_id=np.arange(length, dtype=np.int64),
|
|
171
|
+
study=labels,
|
|
172
|
+
mean_treat=values["mean_treat"],
|
|
173
|
+
sd_treat=values["sd_treat"],
|
|
174
|
+
n_treat=values["n_treat"],
|
|
175
|
+
mean_control=values["mean_control"],
|
|
176
|
+
sd_control=values["sd_control"],
|
|
177
|
+
n_control=values["n_control"],
|
|
178
|
+
included=active,
|
|
179
|
+
exclusion_reason=reasons,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _exact_hedges_correction(df: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
184
|
+
"""Return the exact gamma-function correction J(df)."""
|
|
185
|
+
|
|
186
|
+
return np.exp(
|
|
187
|
+
gammaln(df / 2.0) - 0.5 * np.log(df / 2.0) - gammaln((df - 1.0) / 2.0)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def calculate_continuous_effects(
|
|
192
|
+
studies: ContinuousStudies,
|
|
193
|
+
*,
|
|
194
|
+
measure: str,
|
|
195
|
+
smd_variance: str = "LS",
|
|
196
|
+
) -> ContinuousEffectData:
|
|
197
|
+
"""Calculate mean differences or exact-corrected Hedges' g values."""
|
|
198
|
+
|
|
199
|
+
normalized_measure = measure.upper()
|
|
200
|
+
if normalized_measure not in {"MD", "SMD"}:
|
|
201
|
+
raise UnsupportedMethodError("measure must be 'MD' or 'SMD'.")
|
|
202
|
+
normalized_variance = smd_variance.upper().replace("-", "_")
|
|
203
|
+
if normalized_variance in {"LARGE_SAMPLE", "HEDGES_1982"}:
|
|
204
|
+
normalized_variance = "LS"
|
|
205
|
+
if normalized_measure == "SMD" and normalized_variance != "LS":
|
|
206
|
+
raise UnsupportedMethodError(
|
|
207
|
+
"smd_variance currently supports only 'LS' (the Hedges 1982 "
|
|
208
|
+
"large-sample approximation)."
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
included = studies.included
|
|
212
|
+
row_count = len(included)
|
|
213
|
+
effect = np.full(row_count, np.nan, dtype=np.float64)
|
|
214
|
+
variance = np.full(row_count, np.nan, dtype=np.float64)
|
|
215
|
+
pooled_sd = np.full(row_count, np.nan, dtype=np.float64)
|
|
216
|
+
cohen_d = np.full(row_count, np.nan, dtype=np.float64)
|
|
217
|
+
correction_factor = np.full(row_count, np.nan, dtype=np.float64)
|
|
218
|
+
|
|
219
|
+
difference = studies.mean_treat - studies.mean_control
|
|
220
|
+
if normalized_measure == "MD":
|
|
221
|
+
effect[included] = difference[included]
|
|
222
|
+
variance[included] = (
|
|
223
|
+
studies.sd_treat[included] ** 2 / studies.n_treat[included]
|
|
224
|
+
+ studies.sd_control[included] ** 2 / studies.n_control[included]
|
|
225
|
+
)
|
|
226
|
+
else:
|
|
227
|
+
degrees_freedom = studies.n_treat + studies.n_control - 2.0
|
|
228
|
+
pooled_variance = (
|
|
229
|
+
(studies.n_treat - 1.0) * studies.sd_treat**2
|
|
230
|
+
+ (studies.n_control - 1.0) * studies.sd_control**2
|
|
231
|
+
) / degrees_freedom
|
|
232
|
+
invalid_pooled = included & (
|
|
233
|
+
~np.isfinite(pooled_variance) | (pooled_variance <= 0.0)
|
|
234
|
+
)
|
|
235
|
+
if np.any(invalid_pooled):
|
|
236
|
+
rows = np.flatnonzero(invalid_pooled).tolist()
|
|
237
|
+
raise InvalidStudyDataError(
|
|
238
|
+
"SMD requires a finite, strictly positive pooled SD; invalid "
|
|
239
|
+
f"rows: {rows}."
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
pooled_sd[included] = np.sqrt(pooled_variance[included])
|
|
243
|
+
cohen_d[included] = difference[included] / pooled_sd[included]
|
|
244
|
+
correction_factor[included] = _exact_hedges_correction(
|
|
245
|
+
degrees_freedom[included]
|
|
246
|
+
)
|
|
247
|
+
effect[included] = correction_factor[included] * cohen_d[included]
|
|
248
|
+
total_n = studies.n_treat + studies.n_control
|
|
249
|
+
variance[included] = (
|
|
250
|
+
1.0 / studies.n_treat[included]
|
|
251
|
+
+ 1.0 / studies.n_control[included]
|
|
252
|
+
+ effect[included] ** 2 / (2.0 * total_n[included])
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
invalid_variance = included & (~np.isfinite(variance) | (variance <= 0.0))
|
|
256
|
+
if np.any(invalid_variance):
|
|
257
|
+
rows = np.flatnonzero(invalid_variance).tolist()
|
|
258
|
+
raise InvalidStudyDataError(
|
|
259
|
+
"Continuous sampling variances must be finite and strictly positive; "
|
|
260
|
+
f"invalid rows: {rows}."
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
return ContinuousEffectData(
|
|
264
|
+
studies=studies,
|
|
265
|
+
effect=effect,
|
|
266
|
+
variance=variance,
|
|
267
|
+
pooled_sd=pooled_sd,
|
|
268
|
+
cohen_d=cohen_d,
|
|
269
|
+
correction_factor=correction_factor,
|
|
270
|
+
measure=normalized_measure,
|
|
271
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Numerical estimators used by the public meta-analysis API."""
|
|
2
|
+
|
|
3
|
+
from .inverse_variance import InverseVarianceFit, fit_inverse_variance
|
|
4
|
+
from .mantel_haenszel import MantelHaenszelFit, fit_mantel_haenszel
|
|
5
|
+
from .tau2 import Tau2Estimate, estimate_tau2
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"InverseVarianceFit",
|
|
9
|
+
"MantelHaenszelFit",
|
|
10
|
+
"Tau2Estimate",
|
|
11
|
+
"estimate_tau2",
|
|
12
|
+
"fit_inverse_variance",
|
|
13
|
+
"fit_mantel_haenszel",
|
|
14
|
+
]
|