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,546 @@
|
|
|
1
|
+
"""Leave-one-out and cumulative meta-analysis workflows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Hashable, Mapping
|
|
6
|
+
from dataclasses import dataclass, field, replace
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pandas as pd
|
|
12
|
+
from numpy.typing import NDArray
|
|
13
|
+
|
|
14
|
+
from .data import ColumnOrArray, MissingPolicy
|
|
15
|
+
from .exceptions import (
|
|
16
|
+
InsufficientStudiesError,
|
|
17
|
+
InvalidStudyDataError,
|
|
18
|
+
UnsupportedMethodError,
|
|
19
|
+
)
|
|
20
|
+
from .provenance import remap_provenance_rows
|
|
21
|
+
from .results import MetaAnalysisResult, SubgroupMetaAnalysisResult
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class LeaveOneOutResult:
|
|
26
|
+
"""Repeated fits obtained by omitting each included study in turn."""
|
|
27
|
+
|
|
28
|
+
original: MetaAnalysisResult
|
|
29
|
+
results: tuple[MetaAnalysisResult, ...]
|
|
30
|
+
warnings: tuple[str, ...]
|
|
31
|
+
_table: pd.DataFrame = field(repr=False, compare=False)
|
|
32
|
+
|
|
33
|
+
def __post_init__(self) -> None:
|
|
34
|
+
object.__setattr__(self, "_table", self._table.copy(deep=True))
|
|
35
|
+
|
|
36
|
+
def __len__(self) -> int:
|
|
37
|
+
return len(self.results)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def table(self) -> pd.DataFrame:
|
|
41
|
+
"""Return one row per omitted study and refitted model."""
|
|
42
|
+
|
|
43
|
+
return self._table.copy(deep=True)
|
|
44
|
+
|
|
45
|
+
def summary(self) -> pd.DataFrame:
|
|
46
|
+
"""Return the tabular leave-one-out summary."""
|
|
47
|
+
|
|
48
|
+
return self.table
|
|
49
|
+
|
|
50
|
+
def to_dataframe(self) -> pd.DataFrame:
|
|
51
|
+
"""Return the tabular leave-one-out summary."""
|
|
52
|
+
|
|
53
|
+
return self.table
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class CumulativeMetaAnalysisResult:
|
|
58
|
+
"""Repeated fits obtained while accumulating studies in a stable order."""
|
|
59
|
+
|
|
60
|
+
original: MetaAnalysisResult
|
|
61
|
+
results: tuple[MetaAnalysisResult, ...]
|
|
62
|
+
warnings: tuple[str, ...]
|
|
63
|
+
_table: pd.DataFrame = field(repr=False, compare=False)
|
|
64
|
+
|
|
65
|
+
def __post_init__(self) -> None:
|
|
66
|
+
object.__setattr__(self, "_table", self._table.copy(deep=True))
|
|
67
|
+
|
|
68
|
+
def __len__(self) -> int:
|
|
69
|
+
return len(self.results)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def final(self) -> MetaAnalysisResult:
|
|
73
|
+
"""Return the final fit containing every originally included study."""
|
|
74
|
+
|
|
75
|
+
return self.results[-1]
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def table(self) -> pd.DataFrame:
|
|
79
|
+
"""Return one row per estimable cumulative fit."""
|
|
80
|
+
|
|
81
|
+
return self._table.copy(deep=True)
|
|
82
|
+
|
|
83
|
+
def summary(self) -> pd.DataFrame:
|
|
84
|
+
"""Return the tabular cumulative summary."""
|
|
85
|
+
|
|
86
|
+
return self.table
|
|
87
|
+
|
|
88
|
+
def to_dataframe(self) -> pd.DataFrame:
|
|
89
|
+
"""Return the tabular cumulative summary."""
|
|
90
|
+
|
|
91
|
+
return self.table
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True, slots=True)
|
|
95
|
+
class SubgroupLeaveOneOutResult:
|
|
96
|
+
"""Leave-one-out results for each subgroup and the overall analysis."""
|
|
97
|
+
|
|
98
|
+
groups: Mapping[Hashable, LeaveOneOutResult]
|
|
99
|
+
overall: LeaveOneOutResult
|
|
100
|
+
|
|
101
|
+
def __post_init__(self) -> None:
|
|
102
|
+
object.__setattr__(self, "groups", MappingProxyType(dict(self.groups)))
|
|
103
|
+
|
|
104
|
+
def to_dataframe(self) -> pd.DataFrame:
|
|
105
|
+
"""Return subgroup and overall leave-one-out rows in one table."""
|
|
106
|
+
|
|
107
|
+
return _combine_subgroup_tables(self.groups, self.overall)
|
|
108
|
+
|
|
109
|
+
def summary(self) -> pd.DataFrame:
|
|
110
|
+
"""Return subgroup and overall leave-one-out rows in one table."""
|
|
111
|
+
|
|
112
|
+
return self.to_dataframe()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True, slots=True)
|
|
116
|
+
class SubgroupCumulativeMetaAnalysisResult:
|
|
117
|
+
"""Cumulative results for each subgroup and the overall analysis."""
|
|
118
|
+
|
|
119
|
+
groups: Mapping[Hashable, CumulativeMetaAnalysisResult]
|
|
120
|
+
overall: CumulativeMetaAnalysisResult
|
|
121
|
+
|
|
122
|
+
def __post_init__(self) -> None:
|
|
123
|
+
object.__setattr__(self, "groups", MappingProxyType(dict(self.groups)))
|
|
124
|
+
|
|
125
|
+
def to_dataframe(self) -> pd.DataFrame:
|
|
126
|
+
"""Return subgroup and overall cumulative rows in one table."""
|
|
127
|
+
|
|
128
|
+
return _combine_subgroup_tables(self.groups, self.overall)
|
|
129
|
+
|
|
130
|
+
def summary(self) -> pd.DataFrame:
|
|
131
|
+
"""Return subgroup and overall cumulative rows in one table."""
|
|
132
|
+
|
|
133
|
+
return self.to_dataframe()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _combine_subgroup_tables(
|
|
137
|
+
groups: Mapping[Hashable, LeaveOneOutResult | CumulativeMetaAnalysisResult],
|
|
138
|
+
overall: LeaveOneOutResult | CumulativeMetaAnalysisResult,
|
|
139
|
+
) -> pd.DataFrame:
|
|
140
|
+
frames: list[pd.DataFrame] = []
|
|
141
|
+
for label, analysis in groups.items():
|
|
142
|
+
table = analysis.to_dataframe()
|
|
143
|
+
table.insert(0, "subgroup", [label for _ in range(len(table))])
|
|
144
|
+
table.insert(0, "scope", "subgroup")
|
|
145
|
+
frames.append(table)
|
|
146
|
+
overall_table = overall.to_dataframe()
|
|
147
|
+
overall_table.insert(0, "subgroup", None)
|
|
148
|
+
overall_table.insert(0, "scope", "overall")
|
|
149
|
+
frames.append(overall_table)
|
|
150
|
+
return pd.concat(frames, ignore_index=True)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _numeric_controls(result: MetaAnalysisResult) -> tuple[float, int]:
|
|
154
|
+
return result.method.atol, result.method.max_iter
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _string_option(
|
|
158
|
+
options: dict[str, str | float | int | bool | None],
|
|
159
|
+
name: str,
|
|
160
|
+
default: str,
|
|
161
|
+
) -> str:
|
|
162
|
+
value = options.get(name, default)
|
|
163
|
+
if not isinstance(value, str):
|
|
164
|
+
raise RuntimeError(f"Stored {name} is not a string.")
|
|
165
|
+
return value
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _float_option(
|
|
169
|
+
options: dict[str, str | float | int | bool | None],
|
|
170
|
+
name: str,
|
|
171
|
+
default: float,
|
|
172
|
+
) -> float:
|
|
173
|
+
value = options.get(name, default)
|
|
174
|
+
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
|
175
|
+
raise RuntimeError(f"Stored {name} is not numeric.")
|
|
176
|
+
return float(value)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _refit(
|
|
180
|
+
result: MetaAnalysisResult, positions: NDArray[np.int64]
|
|
181
|
+
) -> MetaAnalysisResult:
|
|
182
|
+
"""Refit a stored model on selected local row positions."""
|
|
183
|
+
|
|
184
|
+
from .api import meta_analysis
|
|
185
|
+
from .binary_api import meta_binary
|
|
186
|
+
from .continuous_api import meta_continuous
|
|
187
|
+
|
|
188
|
+
studies = result.study_results
|
|
189
|
+
selected = studies.iloc[positions]
|
|
190
|
+
options = dict(result.method.options)
|
|
191
|
+
atol, max_iter = _numeric_controls(result)
|
|
192
|
+
tau2_method = result.method.tau2_method or "REML"
|
|
193
|
+
missing = cast(MissingPolicy, result.method.missing)
|
|
194
|
+
study = selected["study"].to_numpy(dtype=object, copy=True)
|
|
195
|
+
|
|
196
|
+
fitted: MetaAnalysisResult
|
|
197
|
+
if result.measure == "GENERIC":
|
|
198
|
+
fitted = meta_analysis(
|
|
199
|
+
effect=selected["effect"].to_numpy(dtype=np.float64, copy=True),
|
|
200
|
+
variance=selected["variance"].to_numpy(dtype=np.float64, copy=True),
|
|
201
|
+
study=study,
|
|
202
|
+
model=result.model,
|
|
203
|
+
tau2_method=tau2_method,
|
|
204
|
+
ci_method=result.method.ci_method,
|
|
205
|
+
confidence_level=result.method.confidence_level,
|
|
206
|
+
missing=missing,
|
|
207
|
+
atol=atol,
|
|
208
|
+
max_iter=max_iter,
|
|
209
|
+
)
|
|
210
|
+
elif {"event_treat", "n_treat", "event_control", "n_control"}.issubset(
|
|
211
|
+
selected.columns
|
|
212
|
+
):
|
|
213
|
+
fitted = meta_binary(
|
|
214
|
+
event_treat=selected["event_treat"].to_numpy(dtype=np.float64, copy=True),
|
|
215
|
+
n_treat=selected["n_treat"].to_numpy(dtype=np.float64, copy=True),
|
|
216
|
+
event_control=selected["event_control"].to_numpy(
|
|
217
|
+
dtype=np.float64, copy=True
|
|
218
|
+
),
|
|
219
|
+
n_control=selected["n_control"].to_numpy(dtype=np.float64, copy=True),
|
|
220
|
+
measure=result.measure,
|
|
221
|
+
method=result.method.pooling_method,
|
|
222
|
+
continuity_correction=_float_option(options, "continuity_correction", 0.5),
|
|
223
|
+
correction_scope=_string_option(
|
|
224
|
+
options, "correction_scope", "only_zero_studies"
|
|
225
|
+
),
|
|
226
|
+
rd_zero_variance=_string_option(options, "rd_zero_variance", "correct"),
|
|
227
|
+
mh_continuity_correction=_float_option(
|
|
228
|
+
options, "mh_continuity_correction", 0.0
|
|
229
|
+
),
|
|
230
|
+
mh_correction_scope=_string_option(
|
|
231
|
+
options, "mh_correction_scope", "only_zero_studies"
|
|
232
|
+
),
|
|
233
|
+
study=study,
|
|
234
|
+
model=result.model,
|
|
235
|
+
tau2_method=tau2_method,
|
|
236
|
+
ci_method=result.method.ci_method,
|
|
237
|
+
confidence_level=result.method.confidence_level,
|
|
238
|
+
missing=missing,
|
|
239
|
+
atol=atol,
|
|
240
|
+
max_iter=max_iter,
|
|
241
|
+
)
|
|
242
|
+
elif {
|
|
243
|
+
"mean_treat",
|
|
244
|
+
"sd_treat",
|
|
245
|
+
"n_treat",
|
|
246
|
+
"mean_control",
|
|
247
|
+
"sd_control",
|
|
248
|
+
"n_control",
|
|
249
|
+
}.issubset(selected.columns):
|
|
250
|
+
fitted = meta_continuous(
|
|
251
|
+
mean_treat=selected["mean_treat"].to_numpy(dtype=np.float64, copy=True),
|
|
252
|
+
sd_treat=selected["sd_treat"].to_numpy(dtype=np.float64, copy=True),
|
|
253
|
+
n_treat=selected["n_treat"].to_numpy(dtype=np.float64, copy=True),
|
|
254
|
+
mean_control=selected["mean_control"].to_numpy(dtype=np.float64, copy=True),
|
|
255
|
+
sd_control=selected["sd_control"].to_numpy(dtype=np.float64, copy=True),
|
|
256
|
+
n_control=selected["n_control"].to_numpy(dtype=np.float64, copy=True),
|
|
257
|
+
measure=result.measure,
|
|
258
|
+
smd_variance=_string_option(options, "smd_variance", "LS"),
|
|
259
|
+
study=study,
|
|
260
|
+
model=result.model,
|
|
261
|
+
tau2_method=tau2_method,
|
|
262
|
+
ci_method=result.method.ci_method,
|
|
263
|
+
confidence_level=result.method.confidence_level,
|
|
264
|
+
missing=missing,
|
|
265
|
+
atol=atol,
|
|
266
|
+
max_iter=max_iter,
|
|
267
|
+
)
|
|
268
|
+
else: # pragma: no cover - guarded by constructors in this package
|
|
269
|
+
raise UnsupportedMethodError(
|
|
270
|
+
f"Cannot reconstruct inputs for measure={result.measure!r}."
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
refitted_studies = fitted.study_results
|
|
274
|
+
refitted_studies["row_id"] = selected["row_id"].to_numpy(copy=True)
|
|
275
|
+
source = result.source_data
|
|
276
|
+
selected_source = None if source is None else source.iloc[positions].copy(deep=True)
|
|
277
|
+
return replace(
|
|
278
|
+
fitted,
|
|
279
|
+
provenance=remap_provenance_rows(
|
|
280
|
+
fitted.provenance,
|
|
281
|
+
selected["row_id"].to_numpy(dtype=np.int64, copy=True).tolist(),
|
|
282
|
+
),
|
|
283
|
+
_study_results=refitted_studies,
|
|
284
|
+
_source_data=selected_source,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _fit_summary(result: MetaAnalysisResult) -> dict[str, Any]:
|
|
289
|
+
display_low, display_high = result.display_ci
|
|
290
|
+
return {
|
|
291
|
+
"k": result.k,
|
|
292
|
+
"estimate": result.estimate,
|
|
293
|
+
"standard_error": result.standard_error,
|
|
294
|
+
"ci_low": result.ci_low,
|
|
295
|
+
"ci_high": result.ci_high,
|
|
296
|
+
"display_estimate": result.display_estimate,
|
|
297
|
+
"display_ci_low": display_low,
|
|
298
|
+
"display_ci_high": display_high,
|
|
299
|
+
"tau2": result.tau2,
|
|
300
|
+
"q": result.q,
|
|
301
|
+
"q_df": result.q_df,
|
|
302
|
+
"q_pvalue": result.q_pvalue,
|
|
303
|
+
"i2": result.i2,
|
|
304
|
+
"h2": result.h2,
|
|
305
|
+
"i2_method": result.i2_method,
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def leave_one_out(result: MetaAnalysisResult) -> LeaveOneOutResult:
|
|
310
|
+
"""Refit the same model while omitting each included study once."""
|
|
311
|
+
|
|
312
|
+
studies = result.study_results
|
|
313
|
+
included = np.flatnonzero(
|
|
314
|
+
studies["included"].to_numpy(dtype=np.bool_, copy=True)
|
|
315
|
+
).astype(np.int64, copy=False)
|
|
316
|
+
minimum = 3 if result.model == "random" else 2
|
|
317
|
+
if len(included) < minimum:
|
|
318
|
+
requirement = (
|
|
319
|
+
"three included studies for a random-effects model"
|
|
320
|
+
if result.model == "random"
|
|
321
|
+
else "two included studies"
|
|
322
|
+
)
|
|
323
|
+
raise InsufficientStudiesError(
|
|
324
|
+
f"Leave-one-out analysis requires at least {requirement}."
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
fitted_results: list[MetaAnalysisResult] = []
|
|
328
|
+
rows: list[dict[str, Any]] = []
|
|
329
|
+
for omitted in included:
|
|
330
|
+
retained = included[included != omitted]
|
|
331
|
+
fitted = _refit(result, retained)
|
|
332
|
+
fitted_results.append(fitted)
|
|
333
|
+
source_row = studies.iloc[int(omitted)]
|
|
334
|
+
rows.append(
|
|
335
|
+
{
|
|
336
|
+
"omitted_row_id": source_row["row_id"],
|
|
337
|
+
"omitted_study": source_row["study"],
|
|
338
|
+
**_fit_summary(fitted),
|
|
339
|
+
}
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
return LeaveOneOutResult(
|
|
343
|
+
original=result,
|
|
344
|
+
results=tuple(fitted_results),
|
|
345
|
+
warnings=(),
|
|
346
|
+
_table=pd.DataFrame(rows),
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _order_values(
|
|
351
|
+
result: MetaAnalysisResult,
|
|
352
|
+
order: ColumnOrArray | None,
|
|
353
|
+
) -> NDArray[np.object_]:
|
|
354
|
+
studies = result.study_results
|
|
355
|
+
row_count = len(studies)
|
|
356
|
+
if order is None:
|
|
357
|
+
return np.asarray(np.arange(row_count), dtype=object)
|
|
358
|
+
if isinstance(order, str):
|
|
359
|
+
source = result.source_data
|
|
360
|
+
if source is not None and order in source.columns:
|
|
361
|
+
values = source[order].to_numpy(copy=True)
|
|
362
|
+
elif order in studies.columns:
|
|
363
|
+
values = studies[order].to_numpy(copy=True)
|
|
364
|
+
else:
|
|
365
|
+
raise InvalidStudyDataError(
|
|
366
|
+
f"Order column {order!r} is not present in the source data or "
|
|
367
|
+
"study results."
|
|
368
|
+
)
|
|
369
|
+
else:
|
|
370
|
+
values = np.asarray(order, dtype=object)
|
|
371
|
+
if values.ndim != 1:
|
|
372
|
+
raise InvalidStudyDataError(
|
|
373
|
+
f"order must be one-dimensional, got shape {values.shape}."
|
|
374
|
+
)
|
|
375
|
+
if len(values) != row_count:
|
|
376
|
+
raise InvalidStudyDataError(
|
|
377
|
+
f"order has length {len(values)}, but the analysis has {row_count} rows."
|
|
378
|
+
)
|
|
379
|
+
return np.asarray(values, dtype=object)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _ordered_batches(
|
|
383
|
+
result: MetaAnalysisResult,
|
|
384
|
+
*,
|
|
385
|
+
order: ColumnOrArray | None,
|
|
386
|
+
ascending: bool,
|
|
387
|
+
collapse: bool,
|
|
388
|
+
) -> tuple[list[NDArray[np.int64]], NDArray[np.object_]]:
|
|
389
|
+
if not isinstance(ascending, bool):
|
|
390
|
+
raise InvalidStudyDataError("ascending must be a boolean.")
|
|
391
|
+
if not isinstance(collapse, bool):
|
|
392
|
+
raise InvalidStudyDataError("collapse must be a boolean.")
|
|
393
|
+
if collapse and order is None:
|
|
394
|
+
raise InvalidStudyDataError("collapse=True requires an explicit order.")
|
|
395
|
+
|
|
396
|
+
studies = result.study_results
|
|
397
|
+
included = np.flatnonzero(
|
|
398
|
+
studies["included"].to_numpy(dtype=np.bool_, copy=True)
|
|
399
|
+
).astype(np.int64, copy=False)
|
|
400
|
+
values = _order_values(result, order)
|
|
401
|
+
included_values = values[included]
|
|
402
|
+
missing = np.asarray(pd.isna(included_values), dtype=np.bool_)
|
|
403
|
+
if np.any(missing):
|
|
404
|
+
rows = included[np.flatnonzero(missing)].tolist()
|
|
405
|
+
raise InvalidStudyDataError(
|
|
406
|
+
f"Order values must not be missing for included studies; rows: {rows}."
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
sortable = pd.Series(included_values, index=included)
|
|
410
|
+
try:
|
|
411
|
+
sorted_series = sortable.sort_values(
|
|
412
|
+
ascending=ascending,
|
|
413
|
+
kind="mergesort",
|
|
414
|
+
)
|
|
415
|
+
except TypeError as error:
|
|
416
|
+
raise InvalidStudyDataError(
|
|
417
|
+
"Order values must be mutually sortable."
|
|
418
|
+
) from error
|
|
419
|
+
sorted_positions = sorted_series.index.to_numpy(dtype=np.int64, copy=True)
|
|
420
|
+
sorted_values = sorted_series.to_numpy(dtype=object, copy=True)
|
|
421
|
+
if not collapse:
|
|
422
|
+
return [
|
|
423
|
+
np.asarray([position], dtype=np.int64) for position in sorted_positions
|
|
424
|
+
], values
|
|
425
|
+
|
|
426
|
+
batches: list[NDArray[np.int64]] = []
|
|
427
|
+
start = 0
|
|
428
|
+
for index in range(1, len(sorted_positions) + 1):
|
|
429
|
+
at_end = index == len(sorted_positions)
|
|
430
|
+
changed = not at_end and sorted_values[index] != sorted_values[start]
|
|
431
|
+
if at_end or changed:
|
|
432
|
+
batches.append(sorted_positions[start:index])
|
|
433
|
+
start = index
|
|
434
|
+
return batches, values
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def cumulative(
|
|
438
|
+
result: MetaAnalysisResult,
|
|
439
|
+
*,
|
|
440
|
+
order: ColumnOrArray | None = None,
|
|
441
|
+
ascending: bool = True,
|
|
442
|
+
collapse: bool = False,
|
|
443
|
+
) -> CumulativeMetaAnalysisResult:
|
|
444
|
+
"""Refit the same model while adding studies in a stable order."""
|
|
445
|
+
|
|
446
|
+
batches, order_values = _ordered_batches(
|
|
447
|
+
result,
|
|
448
|
+
order=order,
|
|
449
|
+
ascending=ascending,
|
|
450
|
+
collapse=collapse,
|
|
451
|
+
)
|
|
452
|
+
studies = result.study_results
|
|
453
|
+
minimum = 2 if result.model == "random" else 1
|
|
454
|
+
warnings: tuple[str, ...] = ()
|
|
455
|
+
if minimum == 2:
|
|
456
|
+
warnings = (
|
|
457
|
+
"Random-effects cumulative analysis begins at k=2 because tau-squared "
|
|
458
|
+
"is not estimable from a single study in this library.",
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
prefix: list[int] = []
|
|
462
|
+
pending_added: list[int] = []
|
|
463
|
+
fitted_results: list[MetaAnalysisResult] = []
|
|
464
|
+
rows: list[dict[str, Any]] = []
|
|
465
|
+
for batch in batches:
|
|
466
|
+
batch_list = batch.tolist()
|
|
467
|
+
prefix.extend(batch_list)
|
|
468
|
+
pending_added.extend(batch_list)
|
|
469
|
+
if len(prefix) < minimum:
|
|
470
|
+
continue
|
|
471
|
+
positions = np.asarray(prefix, dtype=np.int64)
|
|
472
|
+
fitted = _refit(result, positions)
|
|
473
|
+
fitted_results.append(fitted)
|
|
474
|
+
added_rows = studies.iloc[pending_added]
|
|
475
|
+
last_position = int(batch[-1])
|
|
476
|
+
rows.append(
|
|
477
|
+
{
|
|
478
|
+
"step": len(fitted_results),
|
|
479
|
+
"added_row_ids": tuple(added_rows["row_id"].tolist()),
|
|
480
|
+
"added_studies": tuple(added_rows["study"].tolist()),
|
|
481
|
+
"order_value": order_values[last_position],
|
|
482
|
+
**_fit_summary(fitted),
|
|
483
|
+
}
|
|
484
|
+
)
|
|
485
|
+
pending_added.clear()
|
|
486
|
+
|
|
487
|
+
return CumulativeMetaAnalysisResult(
|
|
488
|
+
original=result,
|
|
489
|
+
results=tuple(fitted_results),
|
|
490
|
+
warnings=warnings,
|
|
491
|
+
_table=pd.DataFrame(rows),
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def subgroup_leave_one_out(
|
|
496
|
+
result: SubgroupMetaAnalysisResult,
|
|
497
|
+
) -> SubgroupLeaveOneOutResult:
|
|
498
|
+
"""Run leave-one-out analyses within every subgroup and overall."""
|
|
499
|
+
|
|
500
|
+
groups: dict[Hashable, LeaveOneOutResult] = {}
|
|
501
|
+
for label, group in result.groups.items():
|
|
502
|
+
try:
|
|
503
|
+
groups[label] = leave_one_out(group)
|
|
504
|
+
except InsufficientStudiesError as error:
|
|
505
|
+
raise InsufficientStudiesError(f"Subgroup {label!r}: {error}") from error
|
|
506
|
+
return SubgroupLeaveOneOutResult(
|
|
507
|
+
groups=groups,
|
|
508
|
+
overall=leave_one_out(result.overall),
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def subgroup_cumulative(
|
|
513
|
+
result: SubgroupMetaAnalysisResult,
|
|
514
|
+
*,
|
|
515
|
+
order: ColumnOrArray | None = None,
|
|
516
|
+
ascending: bool = True,
|
|
517
|
+
collapse: bool = False,
|
|
518
|
+
) -> SubgroupCumulativeMetaAnalysisResult:
|
|
519
|
+
"""Run cumulative analyses within every subgroup and overall."""
|
|
520
|
+
|
|
521
|
+
full_order = _order_values(result.overall, order)
|
|
522
|
+
groups: dict[Hashable, CumulativeMetaAnalysisResult] = {}
|
|
523
|
+
for label, group in result.groups.items():
|
|
524
|
+
group_order: ColumnOrArray | None
|
|
525
|
+
if order is None:
|
|
526
|
+
group_order = None
|
|
527
|
+
elif isinstance(order, str):
|
|
528
|
+
group_order = order
|
|
529
|
+
else:
|
|
530
|
+
row_ids = group.study_results["row_id"].to_numpy(dtype=np.int64, copy=True)
|
|
531
|
+
group_order = full_order[row_ids]
|
|
532
|
+
groups[label] = cumulative(
|
|
533
|
+
group,
|
|
534
|
+
order=group_order,
|
|
535
|
+
ascending=ascending,
|
|
536
|
+
collapse=collapse,
|
|
537
|
+
)
|
|
538
|
+
return SubgroupCumulativeMetaAnalysisResult(
|
|
539
|
+
groups=groups,
|
|
540
|
+
overall=cumulative(
|
|
541
|
+
result.overall,
|
|
542
|
+
order=order,
|
|
543
|
+
ascending=ascending,
|
|
544
|
+
collapse=collapse,
|
|
545
|
+
),
|
|
546
|
+
)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Construction and formal comparison of independent study subgroups."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Hashable
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
from numpy.typing import NDArray
|
|
12
|
+
from scipy.stats import chi2
|
|
13
|
+
|
|
14
|
+
from .config import SubgroupMethodConfig
|
|
15
|
+
from .data import ColumnOrArray, _resolve_vector
|
|
16
|
+
from .exceptions import InsufficientStudiesError, InvalidStudyDataError
|
|
17
|
+
from .provenance import remap_provenance_rows
|
|
18
|
+
from .results import MetaAnalysisResult, SubgroupMetaAnalysisResult
|
|
19
|
+
|
|
20
|
+
GroupFitter = Callable[[NDArray[np.int64]], MetaAnalysisResult]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _subgroup_labels(
|
|
24
|
+
subgroup: ColumnOrArray,
|
|
25
|
+
*,
|
|
26
|
+
data: pd.DataFrame | None,
|
|
27
|
+
length: int,
|
|
28
|
+
) -> NDArray[np.object_]:
|
|
29
|
+
labels = _resolve_vector(subgroup, data=data, name="subgroup")
|
|
30
|
+
if len(labels) != length:
|
|
31
|
+
raise InvalidStudyDataError(
|
|
32
|
+
f"subgroup has length {len(labels)}, but the analysis has {length} rows."
|
|
33
|
+
)
|
|
34
|
+
missing = np.asarray(pd.isna(labels), dtype=np.bool_)
|
|
35
|
+
if np.any(missing):
|
|
36
|
+
rows = np.flatnonzero(missing).tolist()
|
|
37
|
+
raise InvalidStudyDataError(
|
|
38
|
+
f"Missing subgroup labels are not supported; invalid row positions: {rows}."
|
|
39
|
+
)
|
|
40
|
+
object_labels = np.asarray(labels, dtype=object)
|
|
41
|
+
for position, label in enumerate(object_labels):
|
|
42
|
+
try:
|
|
43
|
+
hash(label)
|
|
44
|
+
except TypeError as error:
|
|
45
|
+
raise InvalidStudyDataError(
|
|
46
|
+
f"subgroup labels must be hashable; invalid row position: {position}."
|
|
47
|
+
) from error
|
|
48
|
+
return object_labels
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _restore_global_rows(
|
|
52
|
+
result: MetaAnalysisResult,
|
|
53
|
+
*,
|
|
54
|
+
positions: NDArray[np.int64],
|
|
55
|
+
label: Hashable,
|
|
56
|
+
source_data: pd.DataFrame | None,
|
|
57
|
+
) -> MetaAnalysisResult:
|
|
58
|
+
studies = result.study_results
|
|
59
|
+
studies["row_id"] = positions
|
|
60
|
+
studies.insert(2, "subgroup", [label for _ in range(len(studies))])
|
|
61
|
+
group_source = (
|
|
62
|
+
None if source_data is None else source_data.iloc[positions].copy(deep=True)
|
|
63
|
+
)
|
|
64
|
+
return replace(
|
|
65
|
+
result,
|
|
66
|
+
provenance=remap_provenance_rows(result.provenance, positions.tolist()),
|
|
67
|
+
_study_results=studies,
|
|
68
|
+
_source_data=group_source,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _between_group_test(
|
|
73
|
+
groups: dict[Hashable, MetaAnalysisResult],
|
|
74
|
+
) -> tuple[float, int, float, float, tuple[str, ...]]:
|
|
75
|
+
standard_errors = np.asarray(
|
|
76
|
+
[group.standard_error for group in groups.values()], dtype=np.float64
|
|
77
|
+
)
|
|
78
|
+
if np.any(~np.isfinite(standard_errors)) or np.any(standard_errors <= 0.0):
|
|
79
|
+
warning = (
|
|
80
|
+
"The subgroup-differences test is unavailable because at least one "
|
|
81
|
+
"subgroup has a non-finite or non-positive pooled standard error."
|
|
82
|
+
)
|
|
83
|
+
return float("nan"), len(groups) - 1, float("nan"), float("nan"), (warning,)
|
|
84
|
+
|
|
85
|
+
estimates = np.asarray(
|
|
86
|
+
[group.estimate for group in groups.values()], dtype=np.float64
|
|
87
|
+
)
|
|
88
|
+
weights = 1.0 / standard_errors**2
|
|
89
|
+
pooled = float(np.dot(weights, estimates) / np.sum(weights))
|
|
90
|
+
q_between = float(np.dot(weights, (estimates - pooled) ** 2))
|
|
91
|
+
df = len(groups) - 1
|
|
92
|
+
pvalue = float(chi2.sf(q_between, df))
|
|
93
|
+
i2 = 0.0 if q_between <= 0.0 else max(0.0, (q_between - df) / q_between)
|
|
94
|
+
return q_between, df, pvalue, i2, ()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def fit_subgroup_analysis(
|
|
98
|
+
*,
|
|
99
|
+
data: pd.DataFrame | None,
|
|
100
|
+
subgroup: ColumnOrArray,
|
|
101
|
+
overall: MetaAnalysisResult,
|
|
102
|
+
fit_group: GroupFitter,
|
|
103
|
+
) -> SubgroupMetaAnalysisResult:
|
|
104
|
+
"""Fit subgroups and compare their pooled effects using a Wald Q test.
|
|
105
|
+
|
|
106
|
+
The comparison follows the RevMan formulation: subgroup summary effects
|
|
107
|
+
are weighted by the inverse square of their pooled standard errors and
|
|
108
|
+
synthesized under a fixed-effect model. Random-effects tau-squared values
|
|
109
|
+
are estimated independently within each subgroup.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
overall_studies = overall.study_results
|
|
113
|
+
labels = _subgroup_labels(subgroup, data=data, length=len(overall_studies))
|
|
114
|
+
try:
|
|
115
|
+
codes, unique_labels = pd.factorize(labels, sort=False)
|
|
116
|
+
except TypeError as error:
|
|
117
|
+
raise InvalidStudyDataError(
|
|
118
|
+
"subgroup labels must be hashable scalars."
|
|
119
|
+
) from error
|
|
120
|
+
|
|
121
|
+
if len(unique_labels) < 2:
|
|
122
|
+
raise InsufficientStudiesError(
|
|
123
|
+
"Subgroup analysis requires at least two distinct subgroup labels."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
included = overall_studies["included"].to_numpy(dtype=np.bool_, copy=True)
|
|
127
|
+
groups: dict[Hashable, MetaAnalysisResult] = {}
|
|
128
|
+
for code, raw_label in enumerate(unique_labels):
|
|
129
|
+
positions = np.flatnonzero(codes == code).astype(np.int64, copy=False)
|
|
130
|
+
label = cast(Hashable, raw_label)
|
|
131
|
+
if not np.any(included[positions]):
|
|
132
|
+
raise InsufficientStudiesError(
|
|
133
|
+
f"Subgroup {label!r} has no included studies after exclusions."
|
|
134
|
+
)
|
|
135
|
+
try:
|
|
136
|
+
group_result = fit_group(positions)
|
|
137
|
+
except InsufficientStudiesError as error:
|
|
138
|
+
raise InsufficientStudiesError(f"Subgroup {label!r}: {error}") from error
|
|
139
|
+
groups[label] = _restore_global_rows(
|
|
140
|
+
group_result,
|
|
141
|
+
positions=positions,
|
|
142
|
+
label=label,
|
|
143
|
+
source_data=data,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
q_between, df, pvalue, i2, test_warnings = _between_group_test(groups)
|
|
147
|
+
combined_studies = overall_studies.copy(deep=True)
|
|
148
|
+
combined_studies.insert(2, "subgroup", labels)
|
|
149
|
+
method = SubgroupMethodConfig(
|
|
150
|
+
model=overall.model,
|
|
151
|
+
tau2_strategy="independent" if overall.model == "random" else "not_applicable",
|
|
152
|
+
test_method="fixed_effect_on_subgroup_estimates",
|
|
153
|
+
subgroup_missing="raise",
|
|
154
|
+
)
|
|
155
|
+
return SubgroupMetaAnalysisResult(
|
|
156
|
+
groups=groups,
|
|
157
|
+
overall=overall,
|
|
158
|
+
q_between=q_between,
|
|
159
|
+
q_between_df=df,
|
|
160
|
+
q_between_pvalue=pvalue,
|
|
161
|
+
i2_between=i2,
|
|
162
|
+
method=method,
|
|
163
|
+
warnings=test_warnings,
|
|
164
|
+
_study_results=combined_studies,
|
|
165
|
+
)
|