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,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
+ )