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,539 @@
1
+ """High-level API for two-group binary 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 (
12
+ _normalize_ci_method,
13
+ _normalize_model,
14
+ _validate_analysis_controls,
15
+ )
16
+ from .config import MethodConfig
17
+ from .data import ColumnOrArray, MissingPolicy
18
+ from .effect_sizes.binary import (
19
+ adjusted_tables,
20
+ calculate_binary_effects,
21
+ normalize_binary_studies,
22
+ normalize_correction_scope,
23
+ normalize_rd_zero_variance,
24
+ validate_correction,
25
+ )
26
+ from .estimators import fit_inverse_variance, fit_mantel_haenszel
27
+ from .exceptions import UnsupportedMethodError
28
+ from .heterogeneity import (
29
+ classical_heterogeneity,
30
+ heterogeneity_at_estimate,
31
+ tau2_inconsistency,
32
+ )
33
+ from .provenance import (
34
+ TransformationRecord,
35
+ add_input_field,
36
+ build_analysis_provenance,
37
+ )
38
+ from .results import (
39
+ FitDiagnostics,
40
+ HeterogeneityResult,
41
+ MetaAnalysisResult,
42
+ SubgroupMetaAnalysisResult,
43
+ )
44
+ from .subgroups import fit_subgroup_analysis
45
+
46
+
47
+ def _normalize_pooling_method(method: str) -> str:
48
+ normalized = method.lower().replace("-", "_")
49
+ if normalized in {"iv", "inverse", "inverse_variance"}:
50
+ return "inverse_variance"
51
+ if normalized in {"mh", "mantel_haenszel"}:
52
+ return "mantel_haenszel"
53
+ raise UnsupportedMethodError(
54
+ "method must be 'MH'/'mantel_haenszel' or 'IV'/'inverse_variance'."
55
+ )
56
+
57
+
58
+ def _fit_meta_binary_single(
59
+ data: pd.DataFrame | None = None,
60
+ *,
61
+ event_treat: ColumnOrArray,
62
+ n_treat: ColumnOrArray,
63
+ event_control: ColumnOrArray,
64
+ n_control: ColumnOrArray,
65
+ study: ColumnOrArray | None = None,
66
+ measure: str = "RR",
67
+ method: str = "MH",
68
+ model: str = "common",
69
+ tau2_method: str = "REML",
70
+ ci_method: str = "normal",
71
+ confidence_level: float = 0.95,
72
+ continuity_correction: float = 0.5,
73
+ correction_scope: str = "only_zero_studies",
74
+ rd_zero_variance: str = "correct",
75
+ mh_continuity_correction: float | None = None,
76
+ mh_correction_scope: str = "only_zero_studies",
77
+ missing: MissingPolicy = "raise",
78
+ atol: float = 1e-10,
79
+ max_iter: int = 1000,
80
+ ) -> MetaAnalysisResult:
81
+ """Pool OR, RR, or RD from two-group binary study counts.
82
+
83
+ Mantel-Haenszel currently supports common-effect OR and RR. It uses raw
84
+ tables by default; set ``mh_continuity_correction`` explicitly when the
85
+ exact pooled estimator is undefined. Study-level effects use the separate
86
+ ``continuity_correction`` setting for display and heterogeneity statistics.
87
+ """
88
+
89
+ confidence_level, atol, max_iter = _validate_analysis_controls(
90
+ confidence_level=confidence_level,
91
+ atol=atol,
92
+ max_iter=max_iter,
93
+ )
94
+ normalized_model = _normalize_model(model)
95
+ normalized_method = _normalize_pooling_method(method)
96
+ normalized_ci = _normalize_ci_method(ci_method)
97
+ normalized_tau2 = tau2_method.upper().replace("-", "_")
98
+ normalized_measure = measure.upper()
99
+ correction = validate_correction(
100
+ continuity_correction, name="continuity_correction"
101
+ )
102
+ mh_correction = validate_correction(
103
+ mh_continuity_correction, name="mh_continuity_correction"
104
+ )
105
+ scope = normalize_correction_scope(correction_scope)
106
+ rd_policy = normalize_rd_zero_variance(rd_zero_variance)
107
+ mh_scope = normalize_correction_scope(mh_correction_scope)
108
+
109
+ if normalized_measure != "RD" and rd_policy != "correct":
110
+ raise UnsupportedMethodError(
111
+ "rd_zero_variance is only configurable when measure='RD'."
112
+ )
113
+
114
+ if normalized_method == "mantel_haenszel":
115
+ if normalized_model != "common":
116
+ raise UnsupportedMethodError(
117
+ "Mantel-Haenszel is currently implemented only for model='common'; "
118
+ "use method='IV' for random-effects models."
119
+ )
120
+ if normalized_measure not in {"OR", "RR"}:
121
+ raise UnsupportedMethodError(
122
+ "Mantel-Haenszel currently supports measure='OR' or measure='RR'; "
123
+ "use method='IV' for risk differences."
124
+ )
125
+ if normalized_ci != "normal":
126
+ raise UnsupportedMethodError(
127
+ "Mantel-Haenszel currently supports only ci_method='normal'."
128
+ )
129
+
130
+ studies = normalize_binary_studies(
131
+ data=data,
132
+ event_treat=event_treat,
133
+ n_treat=n_treat,
134
+ event_control=event_control,
135
+ n_control=n_control,
136
+ study=study,
137
+ missing=missing,
138
+ )
139
+ effects = calculate_binary_effects(
140
+ studies,
141
+ measure=normalized_measure,
142
+ continuity_correction=correction,
143
+ correction_scope=scope,
144
+ rd_zero_variance=rd_policy,
145
+ )
146
+ included = effects.studies.included
147
+ included_effect = effects.included_effect
148
+ included_variance = effects.included_variance
149
+
150
+ warnings: list[str] = []
151
+ if normalized_method == "inverse_variance":
152
+ fit = fit_inverse_variance(
153
+ included_effect,
154
+ included_variance,
155
+ model=normalized_model,
156
+ tau2_method=normalized_tau2,
157
+ ci_method=normalized_ci,
158
+ confidence_level=confidence_level,
159
+ atol=atol,
160
+ max_iter=max_iter,
161
+ )
162
+ estimate = fit.estimate
163
+ standard_error = fit.standard_error
164
+ ci_low = fit.ci_low
165
+ ci_high = fit.ci_high
166
+ prediction_interval = fit.prediction_interval
167
+ weights = fit.weights
168
+ normalized_weights = fit.normalized_weights
169
+ tau2 = 0.0 if fit.tau2 is None else fit.tau2.value
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
+ q_values = classical_heterogeneity(included_effect, included_variance)
176
+ mh_corrected = np.zeros(len(included), dtype=bool)
177
+ warnings.extend(fit.warnings)
178
+ else:
179
+ a, b, c, d, mh_corrected = adjusted_tables(
180
+ effects.studies,
181
+ correction=mh_correction,
182
+ scope=mh_scope,
183
+ )
184
+ mh_fit = fit_mantel_haenszel(
185
+ a[included],
186
+ b[included],
187
+ c[included],
188
+ d[included],
189
+ measure=normalized_measure,
190
+ confidence_level=confidence_level,
191
+ )
192
+ estimate = mh_fit.estimate
193
+ standard_error = mh_fit.standard_error
194
+ ci_low = mh_fit.ci_low
195
+ ci_high = mh_fit.ci_high
196
+ prediction_interval = None
197
+ weights = mh_fit.weights
198
+ normalized_weights = mh_fit.normalized_weights
199
+ tau2 = 0.0
200
+ diagnostics = FitDiagnostics(True, 0, None)
201
+ q_values = heterogeneity_at_estimate(
202
+ included_effect, included_variance, estimate
203
+ )
204
+
205
+ q, q_df, q_pvalue, i2, h2 = q_values
206
+ i2_method = "q_based"
207
+ if normalized_model == "random":
208
+ i2, h2 = tau2_inconsistency(included_variance, tau2)
209
+ i2_method = "tau2_typical_variance"
210
+ heterogeneity = HeterogeneityResult(q, q_df, q_pvalue, i2, h2, i2_method)
211
+ row_count = len(included)
212
+ raw_weights = np.full(row_count, np.nan, dtype=np.float64)
213
+ result_weights = np.full(row_count, np.nan, dtype=np.float64)
214
+ raw_weights[included] = weights
215
+ result_weights[included] = normalized_weights
216
+ effect_display = effects.effect.copy()
217
+ if effects.display_scale == "exp":
218
+ effect_display[included] = np.exp(effect_display[included])
219
+
220
+ study_results = pd.DataFrame(
221
+ {
222
+ "row_id": effects.studies.row_id,
223
+ "study": effects.studies.study,
224
+ "event_treat": effects.studies.event_treat,
225
+ "n_treat": effects.studies.n_treat,
226
+ "event_control": effects.studies.event_control,
227
+ "n_control": effects.studies.n_control,
228
+ "effect": effects.effect,
229
+ "effect_display": effect_display,
230
+ "variance": effects.variance,
231
+ "standard_error": np.sqrt(effects.variance),
232
+ "included": included,
233
+ "exclusion_reason": pd.Series(
234
+ effects.studies.exclusion_reason, dtype=object, copy=True
235
+ ),
236
+ "continuity_corrected": effects.corrected,
237
+ "rd_zero_variance": effects.rd_zero_variance,
238
+ "mh_continuity_corrected": mh_corrected,
239
+ "weight": raw_weights,
240
+ "normalized_weight": result_weights,
241
+ }
242
+ )
243
+
244
+ excluded_count = int(np.count_nonzero(~included))
245
+ corrected_count = int(np.count_nonzero(effects.corrected))
246
+ mh_corrected_count = int(np.count_nonzero(mh_corrected))
247
+ if excluded_count:
248
+ warnings.append(
249
+ f"Excluded {excluded_count} non-informative or missing study row(s)."
250
+ )
251
+ if corrected_count:
252
+ warnings.append(
253
+ f"Applied continuity_correction={correction:g} to "
254
+ f"{corrected_count} study table(s) for individual effects."
255
+ )
256
+ if mh_corrected_count:
257
+ warnings.append(
258
+ f"Applied mh_continuity_correction={mh_correction:g} to "
259
+ f"{mh_corrected_count} study table(s) for MH pooling."
260
+ )
261
+
262
+ method_config = MethodConfig(
263
+ model=normalized_model,
264
+ pooling_method=normalized_method,
265
+ tau2_method=(
266
+ normalized_tau2
267
+ if normalized_model == "random" and normalized_method == "inverse_variance"
268
+ else None
269
+ ),
270
+ ci_method=normalized_ci,
271
+ confidence_level=confidence_level,
272
+ prediction_interval_method=(
273
+ "HTS"
274
+ if normalized_model == "random" and normalized_method == "inverse_variance"
275
+ else None
276
+ ),
277
+ missing=missing,
278
+ atol=atol,
279
+ max_iter=max_iter,
280
+ options=(
281
+ ("continuity_correction", correction),
282
+ ("correction_scope", scope),
283
+ *((("rd_zero_variance", rd_policy),) if normalized_measure == "RD" else ()),
284
+ ("mh_continuity_correction", mh_correction),
285
+ ("mh_correction_scope", mh_scope),
286
+ ),
287
+ )
288
+ transformations = [
289
+ TransformationRecord(
290
+ name="binary_effect_size",
291
+ parameters=(
292
+ ("measure", normalized_measure),
293
+ ("model_scale", effects.effect_scale),
294
+ ("display_scale", effects.display_scale),
295
+ ),
296
+ affected_rows=tuple(int(row) for row in np.flatnonzero(included)),
297
+ ),
298
+ TransformationRecord(
299
+ name="continuity_correction",
300
+ parameters=(
301
+ ("value", correction),
302
+ ("scope", scope),
303
+ ("target", "individual_effects"),
304
+ ),
305
+ affected_rows=tuple(int(row) for row in np.flatnonzero(effects.corrected)),
306
+ ),
307
+ ]
308
+ if normalized_measure == "RD":
309
+ transformations.append(
310
+ TransformationRecord(
311
+ name="rd_zero_variance_policy",
312
+ parameters=(
313
+ ("policy", rd_policy),
314
+ ("variance_correction", correction),
315
+ ),
316
+ affected_rows=tuple(
317
+ int(row) for row in np.flatnonzero(effects.rd_zero_variance)
318
+ ),
319
+ )
320
+ )
321
+ relative_exclusions = np.flatnonzero(
322
+ (~included)
323
+ & np.isin(
324
+ effects.studies.exclusion_reason,
325
+ [
326
+ "no events in either group",
327
+ "all participants have events in both groups",
328
+ ],
329
+ )
330
+ )
331
+ if len(relative_exclusions):
332
+ transformations.append(
333
+ TransformationRecord(
334
+ name="relative_effect_exclusion",
335
+ parameters=(("measure", normalized_measure),),
336
+ affected_rows=tuple(int(row) for row in relative_exclusions),
337
+ )
338
+ )
339
+ if normalized_method == "mantel_haenszel":
340
+ transformations.append(
341
+ TransformationRecord(
342
+ name="mantel_haenszel_continuity_correction",
343
+ parameters=(
344
+ ("value", mh_correction),
345
+ ("scope", mh_scope),
346
+ ("target", "pooling"),
347
+ ),
348
+ affected_rows=tuple(int(row) for row in np.flatnonzero(mh_corrected)),
349
+ )
350
+ )
351
+ provenance = build_analysis_provenance(
352
+ analysis_type="binary",
353
+ data=data,
354
+ inputs=(
355
+ ("event_treat", event_treat),
356
+ ("n_treat", n_treat),
357
+ ("event_control", event_control),
358
+ ("n_control", n_control),
359
+ ),
360
+ study=study,
361
+ included=included,
362
+ transformations=tuple(transformations),
363
+ )
364
+ return MetaAnalysisResult(
365
+ estimate=estimate,
366
+ standard_error=standard_error,
367
+ ci_low=ci_low,
368
+ ci_high=ci_high,
369
+ prediction_interval=prediction_interval,
370
+ tau2=tau2,
371
+ heterogeneity=heterogeneity,
372
+ k=len(included_effect),
373
+ model=normalized_model,
374
+ measure=normalized_measure,
375
+ effect_scale=effects.effect_scale,
376
+ display_scale=effects.display_scale,
377
+ method=method_config,
378
+ diagnostics=diagnostics,
379
+ provenance=provenance,
380
+ warnings=tuple(warnings),
381
+ _study_results=study_results,
382
+ _source_data=data,
383
+ )
384
+
385
+
386
+ @overload
387
+ def meta_binary(
388
+ data: pd.DataFrame | None = None,
389
+ *,
390
+ event_treat: ColumnOrArray,
391
+ n_treat: ColumnOrArray,
392
+ event_control: ColumnOrArray,
393
+ n_control: ColumnOrArray,
394
+ study: ColumnOrArray | None = None,
395
+ subgroup: None = None,
396
+ measure: str = "RR",
397
+ method: str = "MH",
398
+ model: str = "common",
399
+ tau2_method: str = "REML",
400
+ ci_method: str = "normal",
401
+ confidence_level: float = 0.95,
402
+ continuity_correction: float = 0.5,
403
+ correction_scope: str = "only_zero_studies",
404
+ rd_zero_variance: str = "correct",
405
+ mh_continuity_correction: float | None = None,
406
+ mh_correction_scope: str = "only_zero_studies",
407
+ missing: MissingPolicy = "raise",
408
+ atol: float = 1e-10,
409
+ max_iter: int = 1000,
410
+ ) -> MetaAnalysisResult: ...
411
+
412
+
413
+ @overload
414
+ def meta_binary(
415
+ data: pd.DataFrame | None = None,
416
+ *,
417
+ event_treat: ColumnOrArray,
418
+ n_treat: ColumnOrArray,
419
+ event_control: ColumnOrArray,
420
+ n_control: ColumnOrArray,
421
+ study: ColumnOrArray | None = None,
422
+ subgroup: ColumnOrArray,
423
+ measure: str = "RR",
424
+ method: str = "MH",
425
+ model: str = "common",
426
+ tau2_method: str = "REML",
427
+ ci_method: str = "normal",
428
+ confidence_level: float = 0.95,
429
+ continuity_correction: float = 0.5,
430
+ correction_scope: str = "only_zero_studies",
431
+ rd_zero_variance: str = "correct",
432
+ mh_continuity_correction: float | None = None,
433
+ mh_correction_scope: str = "only_zero_studies",
434
+ missing: MissingPolicy = "raise",
435
+ atol: float = 1e-10,
436
+ max_iter: int = 1000,
437
+ ) -> SubgroupMetaAnalysisResult: ...
438
+
439
+
440
+ def meta_binary(
441
+ data: pd.DataFrame | None = None,
442
+ *,
443
+ event_treat: ColumnOrArray,
444
+ n_treat: ColumnOrArray,
445
+ event_control: ColumnOrArray,
446
+ n_control: ColumnOrArray,
447
+ study: ColumnOrArray | None = None,
448
+ subgroup: ColumnOrArray | None = None,
449
+ measure: str = "RR",
450
+ method: str = "MH",
451
+ model: str = "common",
452
+ tau2_method: str = "REML",
453
+ ci_method: str = "normal",
454
+ confidence_level: float = 0.95,
455
+ continuity_correction: float = 0.5,
456
+ correction_scope: str = "only_zero_studies",
457
+ rd_zero_variance: str = "correct",
458
+ mh_continuity_correction: float | None = None,
459
+ mh_correction_scope: str = "only_zero_studies",
460
+ missing: MissingPolicy = "raise",
461
+ atol: float = 1e-10,
462
+ max_iter: int = 1000,
463
+ ) -> MetaAnalysisResult | SubgroupMetaAnalysisResult:
464
+ """Pool binary outcomes, optionally fitting independent study subgroups.
465
+
466
+ Event and total arguments accept DataFrame column names or one-dimensional
467
+ array-like values. The default is common-effect Mantel-Haenszel risk-ratio
468
+ pooling. Use inverse-variance pooling for random effects or risk differences.
469
+ For risk differences, ``rd_zero_variance="correct"`` retains boundary
470
+ studies with their raw effect and corrected sampling variance. Use
471
+ ``rd_zero_variance="exclude"`` to remove them before all synthesis
472
+ calculations.
473
+ """
474
+
475
+ overall = _fit_meta_binary_single(
476
+ data,
477
+ event_treat=event_treat,
478
+ n_treat=n_treat,
479
+ event_control=event_control,
480
+ n_control=n_control,
481
+ study=study,
482
+ measure=measure,
483
+ method=method,
484
+ model=model,
485
+ tau2_method=tau2_method,
486
+ ci_method=ci_method,
487
+ confidence_level=confidence_level,
488
+ continuity_correction=continuity_correction,
489
+ correction_scope=correction_scope,
490
+ rd_zero_variance=rd_zero_variance,
491
+ mh_continuity_correction=mh_continuity_correction,
492
+ mh_correction_scope=mh_correction_scope,
493
+ missing=missing,
494
+ atol=atol,
495
+ max_iter=max_iter,
496
+ )
497
+ if subgroup is None:
498
+ return overall
499
+
500
+ overall = replace(
501
+ overall,
502
+ provenance=add_input_field(
503
+ overall.provenance,
504
+ role="subgroup",
505
+ value=subgroup,
506
+ data=data,
507
+ ),
508
+ )
509
+
510
+ def fit_group(positions: np.ndarray) -> MetaAnalysisResult:
511
+ rows = overall.study_results.iloc[positions]
512
+ return _fit_meta_binary_single(
513
+ event_treat=rows["event_treat"].to_numpy(dtype=np.float64, copy=True),
514
+ n_treat=rows["n_treat"].to_numpy(dtype=np.float64, copy=True),
515
+ event_control=rows["event_control"].to_numpy(dtype=np.float64, copy=True),
516
+ n_control=rows["n_control"].to_numpy(dtype=np.float64, copy=True),
517
+ study=rows["study"].to_numpy(dtype=object, copy=True),
518
+ measure=measure,
519
+ method=method,
520
+ model=model,
521
+ tau2_method=tau2_method,
522
+ ci_method=ci_method,
523
+ confidence_level=confidence_level,
524
+ continuity_correction=continuity_correction,
525
+ correction_scope=correction_scope,
526
+ rd_zero_variance=rd_zero_variance,
527
+ mh_continuity_correction=mh_continuity_correction,
528
+ mh_correction_scope=mh_correction_scope,
529
+ missing=missing,
530
+ atol=atol,
531
+ max_iter=max_iter,
532
+ )
533
+
534
+ return fit_subgroup_analysis(
535
+ data=data,
536
+ subgroup=subgroup,
537
+ overall=overall,
538
+ fit_group=fit_group,
539
+ )
meta_analyze/config.py ADDED
@@ -0,0 +1,34 @@
1
+ """Configuration objects recorded in fitted results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TypeAlias
7
+
8
+ MethodOptionValue: TypeAlias = str | float | int | bool | None
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class MethodConfig:
13
+ """The fully resolved methods used to fit a meta-analysis."""
14
+
15
+ model: str
16
+ pooling_method: str
17
+ tau2_method: str | None
18
+ ci_method: str
19
+ confidence_level: float
20
+ prediction_interval_method: str | None
21
+ missing: str
22
+ atol: float
23
+ max_iter: int
24
+ options: tuple[tuple[str, MethodOptionValue], ...]
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class SubgroupMethodConfig:
29
+ """The fully resolved assumptions used for a subgroup analysis."""
30
+
31
+ model: str
32
+ tau2_strategy: str
33
+ test_method: str
34
+ subgroup_missing: str