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,353 @@
|
|
|
1
|
+
"""High-level API for two-group continuous outcome meta-analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from typing import overload
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from .api import _normalize_ci_method, _normalize_model, _validate_analysis_controls
|
|
12
|
+
from .config import MethodConfig, MethodOptionValue
|
|
13
|
+
from .data import ColumnOrArray, MissingPolicy
|
|
14
|
+
from .effect_sizes.continuous import (
|
|
15
|
+
calculate_continuous_effects,
|
|
16
|
+
normalize_continuous_studies,
|
|
17
|
+
)
|
|
18
|
+
from .estimators import fit_inverse_variance
|
|
19
|
+
from .heterogeneity import classical_heterogeneity, tau2_inconsistency
|
|
20
|
+
from .provenance import (
|
|
21
|
+
TransformationRecord,
|
|
22
|
+
add_input_field,
|
|
23
|
+
build_analysis_provenance,
|
|
24
|
+
)
|
|
25
|
+
from .results import (
|
|
26
|
+
FitDiagnostics,
|
|
27
|
+
HeterogeneityResult,
|
|
28
|
+
MetaAnalysisResult,
|
|
29
|
+
SubgroupMetaAnalysisResult,
|
|
30
|
+
)
|
|
31
|
+
from .subgroups import fit_subgroup_analysis
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _fit_meta_continuous_single(
|
|
35
|
+
data: pd.DataFrame | None = None,
|
|
36
|
+
*,
|
|
37
|
+
mean_treat: ColumnOrArray,
|
|
38
|
+
sd_treat: ColumnOrArray,
|
|
39
|
+
n_treat: ColumnOrArray,
|
|
40
|
+
mean_control: ColumnOrArray,
|
|
41
|
+
sd_control: ColumnOrArray,
|
|
42
|
+
n_control: ColumnOrArray,
|
|
43
|
+
study: ColumnOrArray | None = None,
|
|
44
|
+
measure: str = "MD",
|
|
45
|
+
model: str = "random",
|
|
46
|
+
tau2_method: str = "REML",
|
|
47
|
+
ci_method: str = "normal",
|
|
48
|
+
confidence_level: float = 0.95,
|
|
49
|
+
smd_variance: str = "LS",
|
|
50
|
+
missing: MissingPolicy = "raise",
|
|
51
|
+
atol: float = 1e-10,
|
|
52
|
+
max_iter: int = 1000,
|
|
53
|
+
) -> MetaAnalysisResult:
|
|
54
|
+
"""Pool mean differences or Hedges' g from two independent groups.
|
|
55
|
+
|
|
56
|
+
``measure="MD"`` uses the unpooled sampling variance
|
|
57
|
+
``sd_treat**2 / n_treat + sd_control**2 / n_control``. ``measure="SMD"``
|
|
58
|
+
uses a pooled SD, the exact Hedges correction, and the ``LS`` sampling
|
|
59
|
+
variance convention used by ``metafor::escalc(measure="SMD")``.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
confidence_level, atol, max_iter = _validate_analysis_controls(
|
|
63
|
+
confidence_level=confidence_level,
|
|
64
|
+
atol=atol,
|
|
65
|
+
max_iter=max_iter,
|
|
66
|
+
)
|
|
67
|
+
normalized_model = _normalize_model(model)
|
|
68
|
+
normalized_ci = _normalize_ci_method(ci_method)
|
|
69
|
+
normalized_tau2 = tau2_method.upper().replace("-", "_")
|
|
70
|
+
|
|
71
|
+
studies = normalize_continuous_studies(
|
|
72
|
+
data=data,
|
|
73
|
+
mean_treat=mean_treat,
|
|
74
|
+
sd_treat=sd_treat,
|
|
75
|
+
n_treat=n_treat,
|
|
76
|
+
mean_control=mean_control,
|
|
77
|
+
sd_control=sd_control,
|
|
78
|
+
n_control=n_control,
|
|
79
|
+
study=study,
|
|
80
|
+
missing=missing,
|
|
81
|
+
)
|
|
82
|
+
effects = calculate_continuous_effects(
|
|
83
|
+
studies,
|
|
84
|
+
measure=measure,
|
|
85
|
+
smd_variance=smd_variance,
|
|
86
|
+
)
|
|
87
|
+
included = studies.included
|
|
88
|
+
included_effect = effects.included_effect
|
|
89
|
+
included_variance = effects.included_variance
|
|
90
|
+
|
|
91
|
+
fit = fit_inverse_variance(
|
|
92
|
+
included_effect,
|
|
93
|
+
included_variance,
|
|
94
|
+
model=normalized_model,
|
|
95
|
+
tau2_method=normalized_tau2,
|
|
96
|
+
ci_method=normalized_ci,
|
|
97
|
+
confidence_level=confidence_level,
|
|
98
|
+
atol=atol,
|
|
99
|
+
max_iter=max_iter,
|
|
100
|
+
)
|
|
101
|
+
q, q_df, q_pvalue, i2, h2 = classical_heterogeneity(
|
|
102
|
+
included_effect, included_variance
|
|
103
|
+
)
|
|
104
|
+
tau2_value = 0.0 if fit.tau2 is None else fit.tau2.value
|
|
105
|
+
i2_method = "q_based"
|
|
106
|
+
if normalized_model == "random":
|
|
107
|
+
i2, h2 = tau2_inconsistency(included_variance, tau2_value)
|
|
108
|
+
i2_method = "tau2_typical_variance"
|
|
109
|
+
heterogeneity = HeterogeneityResult(q, q_df, q_pvalue, i2, h2, i2_method)
|
|
110
|
+
|
|
111
|
+
row_count = len(included)
|
|
112
|
+
raw_weights = np.full(row_count, np.nan, dtype=np.float64)
|
|
113
|
+
normalized_weights = np.full(row_count, np.nan, dtype=np.float64)
|
|
114
|
+
raw_weights[included] = fit.weights
|
|
115
|
+
normalized_weights[included] = fit.normalized_weights
|
|
116
|
+
study_results = pd.DataFrame(
|
|
117
|
+
{
|
|
118
|
+
"row_id": studies.row_id,
|
|
119
|
+
"study": studies.study,
|
|
120
|
+
"mean_treat": studies.mean_treat,
|
|
121
|
+
"sd_treat": studies.sd_treat,
|
|
122
|
+
"n_treat": studies.n_treat,
|
|
123
|
+
"mean_control": studies.mean_control,
|
|
124
|
+
"sd_control": studies.sd_control,
|
|
125
|
+
"n_control": studies.n_control,
|
|
126
|
+
"effect": effects.effect,
|
|
127
|
+
"effect_display": effects.effect,
|
|
128
|
+
"variance": effects.variance,
|
|
129
|
+
"standard_error": np.sqrt(effects.variance),
|
|
130
|
+
"pooled_sd": effects.pooled_sd,
|
|
131
|
+
"cohen_d": effects.cohen_d,
|
|
132
|
+
"smd_correction_factor": effects.correction_factor,
|
|
133
|
+
"included": included,
|
|
134
|
+
"exclusion_reason": pd.Series(
|
|
135
|
+
studies.exclusion_reason, dtype=object, copy=True
|
|
136
|
+
),
|
|
137
|
+
"weight": raw_weights,
|
|
138
|
+
"normalized_weight": normalized_weights,
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
warnings = list(fit.warnings)
|
|
143
|
+
excluded_count = int(np.count_nonzero(~included))
|
|
144
|
+
if excluded_count:
|
|
145
|
+
warnings.append(
|
|
146
|
+
f"Excluded {excluded_count} study row(s) under missing={missing!r}."
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
options: tuple[tuple[str, MethodOptionValue], ...]
|
|
150
|
+
if effects.measure == "SMD":
|
|
151
|
+
options = (
|
|
152
|
+
("effect_estimator", "hedges_g_exact"),
|
|
153
|
+
("standardizer", "pooled_sd"),
|
|
154
|
+
("smd_variance", "LS"),
|
|
155
|
+
)
|
|
156
|
+
else:
|
|
157
|
+
options = (("sampling_variance", "unpooled"),)
|
|
158
|
+
method = MethodConfig(
|
|
159
|
+
model=normalized_model,
|
|
160
|
+
pooling_method="inverse_variance",
|
|
161
|
+
tau2_method=None if normalized_model == "common" else normalized_tau2,
|
|
162
|
+
ci_method=normalized_ci,
|
|
163
|
+
confidence_level=confidence_level,
|
|
164
|
+
prediction_interval_method="HTS" if normalized_model == "random" else None,
|
|
165
|
+
missing=missing,
|
|
166
|
+
atol=atol,
|
|
167
|
+
max_iter=max_iter,
|
|
168
|
+
options=options,
|
|
169
|
+
)
|
|
170
|
+
diagnostics = FitDiagnostics(
|
|
171
|
+
converged=True if fit.tau2 is None else fit.tau2.converged,
|
|
172
|
+
iterations=0 if fit.tau2 is None else fit.tau2.iterations,
|
|
173
|
+
tau2_at_boundary=None if fit.tau2 is None else fit.tau2.boundary,
|
|
174
|
+
)
|
|
175
|
+
provenance = build_analysis_provenance(
|
|
176
|
+
analysis_type="continuous",
|
|
177
|
+
data=data,
|
|
178
|
+
inputs=(
|
|
179
|
+
("mean_treat", mean_treat),
|
|
180
|
+
("sd_treat", sd_treat),
|
|
181
|
+
("n_treat", n_treat),
|
|
182
|
+
("mean_control", mean_control),
|
|
183
|
+
("sd_control", sd_control),
|
|
184
|
+
("n_control", n_control),
|
|
185
|
+
),
|
|
186
|
+
study=study,
|
|
187
|
+
included=included,
|
|
188
|
+
transformations=(
|
|
189
|
+
TransformationRecord(
|
|
190
|
+
name="continuous_effect_size",
|
|
191
|
+
parameters=(("measure", effects.measure), *options),
|
|
192
|
+
affected_rows=tuple(int(row) for row in np.flatnonzero(included)),
|
|
193
|
+
),
|
|
194
|
+
),
|
|
195
|
+
)
|
|
196
|
+
return MetaAnalysisResult(
|
|
197
|
+
estimate=fit.estimate,
|
|
198
|
+
standard_error=fit.standard_error,
|
|
199
|
+
ci_low=fit.ci_low,
|
|
200
|
+
ci_high=fit.ci_high,
|
|
201
|
+
prediction_interval=fit.prediction_interval,
|
|
202
|
+
tau2=tau2_value,
|
|
203
|
+
heterogeneity=heterogeneity,
|
|
204
|
+
k=len(included_effect),
|
|
205
|
+
model=normalized_model,
|
|
206
|
+
measure=effects.measure,
|
|
207
|
+
effect_scale=effects.effect_scale,
|
|
208
|
+
display_scale=effects.display_scale,
|
|
209
|
+
method=method,
|
|
210
|
+
diagnostics=diagnostics,
|
|
211
|
+
provenance=provenance,
|
|
212
|
+
warnings=tuple(warnings),
|
|
213
|
+
_study_results=study_results,
|
|
214
|
+
_source_data=data,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@overload
|
|
219
|
+
def meta_continuous(
|
|
220
|
+
data: pd.DataFrame | None = None,
|
|
221
|
+
*,
|
|
222
|
+
mean_treat: ColumnOrArray,
|
|
223
|
+
sd_treat: ColumnOrArray,
|
|
224
|
+
n_treat: ColumnOrArray,
|
|
225
|
+
mean_control: ColumnOrArray,
|
|
226
|
+
sd_control: ColumnOrArray,
|
|
227
|
+
n_control: ColumnOrArray,
|
|
228
|
+
study: ColumnOrArray | None = None,
|
|
229
|
+
subgroup: None = None,
|
|
230
|
+
measure: str = "MD",
|
|
231
|
+
model: str = "random",
|
|
232
|
+
tau2_method: str = "REML",
|
|
233
|
+
ci_method: str = "normal",
|
|
234
|
+
confidence_level: float = 0.95,
|
|
235
|
+
smd_variance: str = "LS",
|
|
236
|
+
missing: MissingPolicy = "raise",
|
|
237
|
+
atol: float = 1e-10,
|
|
238
|
+
max_iter: int = 1000,
|
|
239
|
+
) -> MetaAnalysisResult: ...
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@overload
|
|
243
|
+
def meta_continuous(
|
|
244
|
+
data: pd.DataFrame | None = None,
|
|
245
|
+
*,
|
|
246
|
+
mean_treat: ColumnOrArray,
|
|
247
|
+
sd_treat: ColumnOrArray,
|
|
248
|
+
n_treat: ColumnOrArray,
|
|
249
|
+
mean_control: ColumnOrArray,
|
|
250
|
+
sd_control: ColumnOrArray,
|
|
251
|
+
n_control: ColumnOrArray,
|
|
252
|
+
study: ColumnOrArray | None = None,
|
|
253
|
+
subgroup: ColumnOrArray,
|
|
254
|
+
measure: str = "MD",
|
|
255
|
+
model: str = "random",
|
|
256
|
+
tau2_method: str = "REML",
|
|
257
|
+
ci_method: str = "normal",
|
|
258
|
+
confidence_level: float = 0.95,
|
|
259
|
+
smd_variance: str = "LS",
|
|
260
|
+
missing: MissingPolicy = "raise",
|
|
261
|
+
atol: float = 1e-10,
|
|
262
|
+
max_iter: int = 1000,
|
|
263
|
+
) -> SubgroupMetaAnalysisResult: ...
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def meta_continuous(
|
|
267
|
+
data: pd.DataFrame | None = None,
|
|
268
|
+
*,
|
|
269
|
+
mean_treat: ColumnOrArray,
|
|
270
|
+
sd_treat: ColumnOrArray,
|
|
271
|
+
n_treat: ColumnOrArray,
|
|
272
|
+
mean_control: ColumnOrArray,
|
|
273
|
+
sd_control: ColumnOrArray,
|
|
274
|
+
n_control: ColumnOrArray,
|
|
275
|
+
study: ColumnOrArray | None = None,
|
|
276
|
+
subgroup: ColumnOrArray | None = None,
|
|
277
|
+
measure: str = "MD",
|
|
278
|
+
model: str = "random",
|
|
279
|
+
tau2_method: str = "REML",
|
|
280
|
+
ci_method: str = "normal",
|
|
281
|
+
confidence_level: float = 0.95,
|
|
282
|
+
smd_variance: str = "LS",
|
|
283
|
+
missing: MissingPolicy = "raise",
|
|
284
|
+
atol: float = 1e-10,
|
|
285
|
+
max_iter: int = 1000,
|
|
286
|
+
) -> MetaAnalysisResult | SubgroupMetaAnalysisResult:
|
|
287
|
+
"""Pool two-group continuous outcomes, optionally by subgroup.
|
|
288
|
+
|
|
289
|
+
Group means, sample standard deviations, and sample sizes accept DataFrame
|
|
290
|
+
column names or one-dimensional array-like values. The default is a REML
|
|
291
|
+
random-effects mean difference. ``measure="SMD"`` calculates exact-
|
|
292
|
+
corrected Hedges' g with the LS sampling-variance convention.
|
|
293
|
+
"""
|
|
294
|
+
|
|
295
|
+
overall = _fit_meta_continuous_single(
|
|
296
|
+
data,
|
|
297
|
+
mean_treat=mean_treat,
|
|
298
|
+
sd_treat=sd_treat,
|
|
299
|
+
n_treat=n_treat,
|
|
300
|
+
mean_control=mean_control,
|
|
301
|
+
sd_control=sd_control,
|
|
302
|
+
n_control=n_control,
|
|
303
|
+
study=study,
|
|
304
|
+
measure=measure,
|
|
305
|
+
model=model,
|
|
306
|
+
tau2_method=tau2_method,
|
|
307
|
+
ci_method=ci_method,
|
|
308
|
+
confidence_level=confidence_level,
|
|
309
|
+
smd_variance=smd_variance,
|
|
310
|
+
missing=missing,
|
|
311
|
+
atol=atol,
|
|
312
|
+
max_iter=max_iter,
|
|
313
|
+
)
|
|
314
|
+
if subgroup is None:
|
|
315
|
+
return overall
|
|
316
|
+
|
|
317
|
+
overall = replace(
|
|
318
|
+
overall,
|
|
319
|
+
provenance=add_input_field(
|
|
320
|
+
overall.provenance,
|
|
321
|
+
role="subgroup",
|
|
322
|
+
value=subgroup,
|
|
323
|
+
data=data,
|
|
324
|
+
),
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
def fit_group(positions: np.ndarray) -> MetaAnalysisResult:
|
|
328
|
+
rows = overall.study_results.iloc[positions]
|
|
329
|
+
return _fit_meta_continuous_single(
|
|
330
|
+
mean_treat=rows["mean_treat"].to_numpy(dtype=np.float64, copy=True),
|
|
331
|
+
sd_treat=rows["sd_treat"].to_numpy(dtype=np.float64, copy=True),
|
|
332
|
+
n_treat=rows["n_treat"].to_numpy(dtype=np.float64, copy=True),
|
|
333
|
+
mean_control=rows["mean_control"].to_numpy(dtype=np.float64, copy=True),
|
|
334
|
+
sd_control=rows["sd_control"].to_numpy(dtype=np.float64, copy=True),
|
|
335
|
+
n_control=rows["n_control"].to_numpy(dtype=np.float64, copy=True),
|
|
336
|
+
study=rows["study"].to_numpy(dtype=object, copy=True),
|
|
337
|
+
measure=measure,
|
|
338
|
+
model=model,
|
|
339
|
+
tau2_method=tau2_method,
|
|
340
|
+
ci_method=ci_method,
|
|
341
|
+
confidence_level=confidence_level,
|
|
342
|
+
smd_variance=smd_variance,
|
|
343
|
+
missing=missing,
|
|
344
|
+
atol=atol,
|
|
345
|
+
max_iter=max_iter,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
return fit_subgroup_analysis(
|
|
349
|
+
data=data,
|
|
350
|
+
subgroup=subgroup,
|
|
351
|
+
overall=overall,
|
|
352
|
+
fit_group=fit_group,
|
|
353
|
+
)
|
meta_analyze/data.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Input normalization at the pandas/NumPy boundary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Literal, TypeAlias
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
from numpy.typing import ArrayLike, NDArray
|
|
11
|
+
|
|
12
|
+
from .exceptions import InvalidStudyDataError
|
|
13
|
+
|
|
14
|
+
ColumnOrArray: TypeAlias = str | ArrayLike
|
|
15
|
+
MissingPolicy: TypeAlias = Literal["raise", "drop"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class NormalizedStudies:
|
|
20
|
+
"""Validated study vectors plus row-level inclusion metadata."""
|
|
21
|
+
|
|
22
|
+
row_id: NDArray[np.int64]
|
|
23
|
+
study: NDArray[np.object_]
|
|
24
|
+
effect: NDArray[np.float64]
|
|
25
|
+
variance: NDArray[np.float64]
|
|
26
|
+
included: NDArray[np.bool_]
|
|
27
|
+
exclusion_reason: NDArray[np.object_]
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def included_effect(self) -> NDArray[np.float64]:
|
|
31
|
+
return self.effect[self.included]
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def included_variance(self) -> NDArray[np.float64]:
|
|
35
|
+
return self.variance[self.included]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _resolve_vector(
|
|
39
|
+
value: ColumnOrArray,
|
|
40
|
+
*,
|
|
41
|
+
data: pd.DataFrame | None,
|
|
42
|
+
name: str,
|
|
43
|
+
) -> NDArray[Any]:
|
|
44
|
+
if isinstance(value, str):
|
|
45
|
+
if data is None:
|
|
46
|
+
raise InvalidStudyDataError(
|
|
47
|
+
f"{name}={value!r} is a column name, but no DataFrame was provided."
|
|
48
|
+
)
|
|
49
|
+
if value not in data.columns:
|
|
50
|
+
raise InvalidStudyDataError(
|
|
51
|
+
f"Column {value!r}, specified by {name}, is not present in data."
|
|
52
|
+
)
|
|
53
|
+
array = data[value].to_numpy(copy=True)
|
|
54
|
+
else:
|
|
55
|
+
if isinstance(value, (str, bytes)):
|
|
56
|
+
raise InvalidStudyDataError(
|
|
57
|
+
f"{name} must be a column name or 1D array-like."
|
|
58
|
+
)
|
|
59
|
+
array = np.asarray(value)
|
|
60
|
+
|
|
61
|
+
if array.ndim != 1:
|
|
62
|
+
raise InvalidStudyDataError(
|
|
63
|
+
f"{name} must be one-dimensional, got shape {array.shape}."
|
|
64
|
+
)
|
|
65
|
+
return array
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _study_labels(
|
|
69
|
+
study: ColumnOrArray | None,
|
|
70
|
+
*,
|
|
71
|
+
data: pd.DataFrame | None,
|
|
72
|
+
length: int,
|
|
73
|
+
) -> NDArray[np.object_]:
|
|
74
|
+
if study is None:
|
|
75
|
+
labels: NDArray[Any]
|
|
76
|
+
if data is not None:
|
|
77
|
+
labels = data.index.to_numpy(copy=True)
|
|
78
|
+
else:
|
|
79
|
+
labels = np.arange(length, dtype=np.int64)
|
|
80
|
+
else:
|
|
81
|
+
labels = _resolve_vector(study, data=data, name="study")
|
|
82
|
+
|
|
83
|
+
if len(labels) != length:
|
|
84
|
+
raise InvalidStudyDataError(
|
|
85
|
+
f"study has length {len(labels)}, but effect and variance have "
|
|
86
|
+
f"length {length}."
|
|
87
|
+
)
|
|
88
|
+
return np.asarray(labels, dtype=object)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def normalize_studies(
|
|
92
|
+
*,
|
|
93
|
+
data: pd.DataFrame | None,
|
|
94
|
+
effect: ColumnOrArray,
|
|
95
|
+
variance: ColumnOrArray,
|
|
96
|
+
study: ColumnOrArray | None,
|
|
97
|
+
missing: MissingPolicy,
|
|
98
|
+
) -> NormalizedStudies:
|
|
99
|
+
"""Resolve column/array arguments and validate generic effect data."""
|
|
100
|
+
|
|
101
|
+
if data is not None and not isinstance(data, pd.DataFrame):
|
|
102
|
+
raise InvalidStudyDataError("data must be a pandas DataFrame or None.")
|
|
103
|
+
if missing not in {"raise", "drop"}:
|
|
104
|
+
raise InvalidStudyDataError("missing must be either 'raise' or 'drop'.")
|
|
105
|
+
|
|
106
|
+
raw_effect = _resolve_vector(effect, data=data, name="effect")
|
|
107
|
+
raw_variance = _resolve_vector(variance, data=data, name="variance")
|
|
108
|
+
if len(raw_effect) != len(raw_variance):
|
|
109
|
+
raise InvalidStudyDataError(
|
|
110
|
+
"effect and variance must have the same length; "
|
|
111
|
+
f"got {len(raw_effect)} and {len(raw_variance)}."
|
|
112
|
+
)
|
|
113
|
+
if data is not None and len(data) != len(raw_effect):
|
|
114
|
+
raise InvalidStudyDataError(
|
|
115
|
+
"Array-like inputs used with data must have exactly one value per "
|
|
116
|
+
"DataFrame row."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
labels = _study_labels(study, data=data, length=len(raw_effect))
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
effect_values = np.asarray(raw_effect, dtype=np.float64)
|
|
123
|
+
variance_values = np.asarray(raw_variance, dtype=np.float64)
|
|
124
|
+
except (TypeError, ValueError) as error:
|
|
125
|
+
raise InvalidStudyDataError(
|
|
126
|
+
"effect and variance must contain numeric values."
|
|
127
|
+
) from error
|
|
128
|
+
|
|
129
|
+
effect_missing = pd.isna(effect_values)
|
|
130
|
+
variance_missing = pd.isna(variance_values)
|
|
131
|
+
any_missing = effect_missing | variance_missing
|
|
132
|
+
if np.any(any_missing) and missing == "raise":
|
|
133
|
+
rows = np.flatnonzero(any_missing).tolist()
|
|
134
|
+
raise InvalidStudyDataError(
|
|
135
|
+
f"Missing effect or variance values at row positions {rows}; "
|
|
136
|
+
"use missing='drop' to exclude them explicitly."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
finite_effect = np.isfinite(effect_values) | effect_missing
|
|
140
|
+
finite_variance = np.isfinite(variance_values) | variance_missing
|
|
141
|
+
if not np.all(finite_effect):
|
|
142
|
+
rows = np.flatnonzero(~finite_effect).tolist()
|
|
143
|
+
raise InvalidStudyDataError(
|
|
144
|
+
f"Effect values must be finite; invalid rows: {rows}."
|
|
145
|
+
)
|
|
146
|
+
if not np.all(finite_variance):
|
|
147
|
+
rows = np.flatnonzero(~finite_variance).tolist()
|
|
148
|
+
raise InvalidStudyDataError(
|
|
149
|
+
f"Variance values must be finite; invalid rows: {rows}."
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
nonpositive_variance = (~variance_missing) & (variance_values <= 0.0)
|
|
153
|
+
if np.any(nonpositive_variance):
|
|
154
|
+
rows = np.flatnonzero(nonpositive_variance).tolist()
|
|
155
|
+
raise InvalidStudyDataError(
|
|
156
|
+
f"Sampling variances must be strictly positive; invalid rows: {rows}."
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
included = ~any_missing
|
|
160
|
+
reasons = np.full(len(effect_values), None, dtype=object)
|
|
161
|
+
for index in np.flatnonzero(any_missing):
|
|
162
|
+
if effect_missing[index] and variance_missing[index]:
|
|
163
|
+
reasons[index] = "missing effect and variance"
|
|
164
|
+
elif effect_missing[index]:
|
|
165
|
+
reasons[index] = "missing effect"
|
|
166
|
+
else:
|
|
167
|
+
reasons[index] = "missing variance"
|
|
168
|
+
|
|
169
|
+
if not np.any(included):
|
|
170
|
+
raise InvalidStudyDataError(
|
|
171
|
+
"No studies remain after applying the missing-value policy."
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return NormalizedStudies(
|
|
175
|
+
row_id=np.arange(len(effect_values), dtype=np.int64),
|
|
176
|
+
study=labels,
|
|
177
|
+
effect=effect_values,
|
|
178
|
+
variance=variance_values,
|
|
179
|
+
included=included,
|
|
180
|
+
exclusion_reason=reasons,
|
|
181
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Study-level effect-size calculations."""
|
|
2
|
+
|
|
3
|
+
from .binary import BinaryEffectData, BinaryStudies, calculate_binary_effects
|
|
4
|
+
from .continuous import (
|
|
5
|
+
ContinuousEffectData,
|
|
6
|
+
ContinuousStudies,
|
|
7
|
+
calculate_continuous_effects,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"BinaryEffectData",
|
|
12
|
+
"BinaryStudies",
|
|
13
|
+
"ContinuousEffectData",
|
|
14
|
+
"ContinuousStudies",
|
|
15
|
+
"calculate_binary_effects",
|
|
16
|
+
"calculate_continuous_effects",
|
|
17
|
+
]
|