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,519 @@
1
+ """Immutable, inspectable result objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Hashable, Mapping
7
+ from dataclasses import dataclass, field
8
+ from types import MappingProxyType
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ import pandas as pd
12
+
13
+ from .config import MethodConfig, SubgroupMethodConfig
14
+ from .data import ColumnOrArray
15
+ from .provenance import AnalysisProvenance
16
+
17
+ if TYPE_CHECKING:
18
+ from matplotlib.axes import Axes
19
+
20
+ from .reporting import ResultReport
21
+ from .sensitivity import (
22
+ CumulativeMetaAnalysisResult,
23
+ LeaveOneOutResult,
24
+ SubgroupCumulativeMetaAnalysisResult,
25
+ SubgroupLeaveOneOutResult,
26
+ )
27
+ else:
28
+ Axes = Any
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class HeterogeneityResult:
33
+ """Heterogeneity statistics and the definition used for I-squared/H-squared."""
34
+
35
+ q: float
36
+ df: int
37
+ pvalue: float
38
+ i2: float
39
+ h2: float
40
+ i2_method: str = "q_based"
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class FitDiagnostics:
45
+ """Convergence details for the fitted model."""
46
+
47
+ converged: bool
48
+ iterations: int
49
+ tau2_at_boundary: bool | None
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class MetaAnalysisSummary:
54
+ """A printable summary with a machine-readable representation."""
55
+
56
+ _result: MetaAnalysisResult = field(repr=False)
57
+
58
+ def to_dict(self) -> dict[str, Any]:
59
+ result = self._result
60
+ return {
61
+ "model": result.model,
62
+ "pooling_method": result.method.pooling_method,
63
+ "measure": result.measure,
64
+ "effect_scale": result.effect_scale,
65
+ "display_scale": result.display_scale,
66
+ "studies": result.k,
67
+ "estimate": result.estimate,
68
+ "display_estimate": result.display_estimate,
69
+ "standard_error": result.standard_error,
70
+ "confidence_level": result.method.confidence_level,
71
+ "ci_low": result.ci_low,
72
+ "ci_high": result.ci_high,
73
+ "display_ci": result.display_ci,
74
+ "prediction_interval": result.prediction_interval,
75
+ "display_prediction_interval": result.display_prediction_interval,
76
+ "tau2": result.tau2,
77
+ "tau2_method": result.method.tau2_method,
78
+ "atol": result.method.atol,
79
+ "max_iter": result.method.max_iter,
80
+ "q": result.q,
81
+ "q_df": result.q_df,
82
+ "q_pvalue": result.q_pvalue,
83
+ "i2": result.i2,
84
+ "h2": result.h2,
85
+ "i2_method": result.i2_method,
86
+ "warnings": result.warnings,
87
+ "method_options": dict(result.method.options),
88
+ }
89
+
90
+ def __str__(self) -> str:
91
+ result = self._result
92
+ level = 100.0 * result.method.confidence_level
93
+ display_low, display_high = result.display_ci
94
+ title = f"Meta-analysis ({result.model}-effect, {result.measure})"
95
+ lines = [
96
+ title,
97
+ f"Studies: {result.k}",
98
+ (
99
+ f"Estimate: {result.display_estimate:.6g} "
100
+ f"({level:g}% CI {display_low:.6g} to {display_high:.6g})"
101
+ ),
102
+ ]
103
+ if result.model == "random":
104
+ lines.append(f"tau^2: {result.tau2:.6g} ({result.method.tau2_method})")
105
+ if result.prediction_interval is not None:
106
+ display_prediction = result.display_prediction_interval
107
+ assert display_prediction is not None
108
+ low, high = display_prediction
109
+ lines.append(f"Prediction interval: {low:.6g} to {high:.6g}")
110
+
111
+ if result.q_df > 0:
112
+ inconsistency_label = (
113
+ "tau^2/typical-variance"
114
+ if result.i2_method == "tau2_typical_variance"
115
+ else "Q-based"
116
+ )
117
+ lines.extend(
118
+ [
119
+ (f"Q({result.q_df}): {result.q:.6g}, p={result.q_pvalue:.6g}"),
120
+ f"I^2 ({inconsistency_label}): {100.0 * result.i2:.2f}%; "
121
+ f"H^2: {result.h2:.6g}",
122
+ ]
123
+ )
124
+ else:
125
+ lines.append("Heterogeneity: not estimable with one study")
126
+
127
+ lines.append(f"Confidence interval method: {result.method.ci_method}")
128
+ if result.model == "random" and result.method.ci_method == "normal":
129
+ if result.k <= 3:
130
+ lines.append(
131
+ "Method note: with three or fewer studies, compare normal and "
132
+ "Hartung-Knapp intervals; neither method fully resolves the "
133
+ "small-sample uncertainty."
134
+ )
135
+ elif result.tau2 > 0.0:
136
+ lines.append(
137
+ "Method note: consider ci_method='hartung_knapp' as a "
138
+ "t-based random-effects sensitivity analysis."
139
+ )
140
+ if result.warnings:
141
+ lines.append("Notes:")
142
+ lines.extend(f"- {warning}" for warning in result.warnings)
143
+ return "\n".join(lines)
144
+
145
+
146
+ @dataclass(frozen=True, slots=True)
147
+ class SubgroupMetaAnalysisSummary:
148
+ """A printable and machine-readable subgroup analysis summary."""
149
+
150
+ _result: SubgroupMetaAnalysisResult = field(repr=False)
151
+
152
+ def to_dict(self) -> dict[str, Any]:
153
+ """Return nested subgroup, overall, and interaction-test results."""
154
+
155
+ result = self._result
156
+ return {
157
+ "groups": {
158
+ label: group.summary().to_dict()
159
+ for label, group in result.groups.items()
160
+ },
161
+ "overall": result.overall.summary().to_dict(),
162
+ "q_between": result.q_between,
163
+ "q_between_df": result.q_between_df,
164
+ "q_between_pvalue": result.q_between_pvalue,
165
+ "i2_between": result.i2_between,
166
+ "method": {
167
+ "model": result.method.model,
168
+ "tau2_strategy": result.method.tau2_strategy,
169
+ "test_method": result.method.test_method,
170
+ "subgroup_missing": result.method.subgroup_missing,
171
+ },
172
+ "warnings": result.warnings,
173
+ }
174
+
175
+ def __str__(self) -> str:
176
+ result = self._result
177
+ level = 100.0 * result.overall.method.confidence_level
178
+ lines = [
179
+ f"Subgroup meta-analysis ({result.overall.model}-effect, "
180
+ f"{result.overall.measure})",
181
+ f"Groups: {len(result.groups)}; studies: {result.overall.k}",
182
+ ]
183
+ for label, group in result.groups.items():
184
+ low, high = group.display_ci
185
+ lines.append(
186
+ f"- {label}: {group.display_estimate:.6g} "
187
+ f"({level:g}% CI {low:.6g} to {high:.6g}; k={group.k})"
188
+ )
189
+ lines.append(
190
+ "Test for subgroup differences: "
191
+ f"Q({result.q_between_df})={result.q_between:.6g}, "
192
+ f"p={result.q_between_pvalue:.6g}, "
193
+ f"I^2={100.0 * result.i2_between:.2f}%"
194
+ )
195
+ lines.append(
196
+ f"Subgroup tau^2 strategy: {result.method.tau2_strategy}; "
197
+ f"test method: {result.method.test_method}"
198
+ )
199
+ if result.warnings:
200
+ lines.append("Notes:")
201
+ lines.extend(f"- {warning}" for warning in result.warnings)
202
+ return "\n".join(lines)
203
+
204
+
205
+ @dataclass(frozen=True, slots=True)
206
+ class MetaAnalysisResult:
207
+ """Results of a generic inverse-variance meta-analysis."""
208
+
209
+ estimate: float
210
+ standard_error: float
211
+ ci_low: float
212
+ ci_high: float
213
+ prediction_interval: tuple[float, float] | None
214
+ tau2: float
215
+ heterogeneity: HeterogeneityResult
216
+ k: int
217
+ model: str
218
+ measure: str
219
+ effect_scale: str
220
+ display_scale: str
221
+ method: MethodConfig
222
+ diagnostics: FitDiagnostics
223
+ provenance: AnalysisProvenance
224
+ warnings: tuple[str, ...]
225
+ _study_results: pd.DataFrame = field(repr=False, compare=False)
226
+ _source_data: pd.DataFrame | None = field(default=None, repr=False, compare=False)
227
+
228
+ def __post_init__(self) -> None:
229
+ object.__setattr__(self, "_study_results", self._study_results.copy(deep=True))
230
+ if self._source_data is not None:
231
+ object.__setattr__(self, "_source_data", self._source_data.copy(deep=True))
232
+
233
+ @property
234
+ def ci(self) -> tuple[float, float]:
235
+ return self.ci_low, self.ci_high
236
+
237
+ def _to_display_scale(self, value: float) -> float:
238
+ if self.display_scale == "identity":
239
+ return value
240
+ if self.display_scale == "exp":
241
+ return math.exp(value)
242
+ raise ValueError(f"Unknown display scale {self.display_scale!r}.")
243
+
244
+ @property
245
+ def display_estimate(self) -> float:
246
+ return self._to_display_scale(self.estimate)
247
+
248
+ @property
249
+ def display_ci(self) -> tuple[float, float]:
250
+ return (
251
+ self._to_display_scale(self.ci_low),
252
+ self._to_display_scale(self.ci_high),
253
+ )
254
+
255
+ @property
256
+ def display_prediction_interval(self) -> tuple[float, float] | None:
257
+ if self.prediction_interval is None:
258
+ return None
259
+ low, high = self.prediction_interval
260
+ return self._to_display_scale(low), self._to_display_scale(high)
261
+
262
+ @property
263
+ def q(self) -> float:
264
+ return self.heterogeneity.q
265
+
266
+ @property
267
+ def q_df(self) -> int:
268
+ return self.heterogeneity.df
269
+
270
+ @property
271
+ def q_pvalue(self) -> float:
272
+ return self.heterogeneity.pvalue
273
+
274
+ @property
275
+ def i2(self) -> float:
276
+ """I-squared as a proportion from 0 to 1."""
277
+
278
+ return self.heterogeneity.i2
279
+
280
+ @property
281
+ def h2(self) -> float:
282
+ return self.heterogeneity.h2
283
+
284
+ @property
285
+ def i2_method(self) -> str:
286
+ return self.heterogeneity.i2_method
287
+
288
+ @property
289
+ def study_results(self) -> pd.DataFrame:
290
+ """Return a defensive copy of row-level inputs and fitted weights."""
291
+
292
+ return self._study_results.copy(deep=True)
293
+
294
+ @property
295
+ def excluded_studies(self) -> pd.DataFrame:
296
+ studies = self._study_results
297
+ return studies.loc[~studies["included"]].copy(deep=True)
298
+
299
+ @property
300
+ def source_data(self) -> pd.DataFrame | None:
301
+ """Return a defensive copy of the source DataFrame, when one was supplied."""
302
+
303
+ if self._source_data is None:
304
+ return None
305
+ return self._source_data.copy(deep=True)
306
+
307
+ def summary(self) -> MetaAnalysisSummary:
308
+ return MetaAnalysisSummary(self)
309
+
310
+ def method_details(self) -> str:
311
+ """Return a concise Methods-style description of the fitted analysis."""
312
+
313
+ from .reporting import method_details
314
+
315
+ return method_details(self)
316
+
317
+ def report(self, *, include_studies: bool = True) -> ResultReport:
318
+ """Build a structured report with JSON and Markdown representations."""
319
+
320
+ from .reporting import build_report
321
+
322
+ return build_report(self, include_studies=include_studies)
323
+
324
+ def to_dataframe(self) -> pd.DataFrame:
325
+ """Return the same row-level table as :attr:`study_results`."""
326
+
327
+ return self.study_results
328
+
329
+ def leave_one_out(self) -> LeaveOneOutResult:
330
+ """Repeatedly refit the model while omitting one included study."""
331
+
332
+ from .sensitivity import leave_one_out
333
+
334
+ return leave_one_out(self)
335
+
336
+ def cumulative(
337
+ self,
338
+ *,
339
+ order: ColumnOrArray | None = None,
340
+ ascending: bool = True,
341
+ collapse: bool = False,
342
+ ) -> CumulativeMetaAnalysisResult:
343
+ """Repeatedly refit the model while accumulating included studies."""
344
+
345
+ from .sensitivity import cumulative
346
+
347
+ return cumulative(
348
+ self,
349
+ order=order,
350
+ ascending=ascending,
351
+ collapse=collapse,
352
+ )
353
+
354
+ def forest(
355
+ self,
356
+ *,
357
+ ax: Axes | None = None,
358
+ effect_label: str | None = None,
359
+ pooled_label: str | None = None,
360
+ show_prediction_interval: bool = True,
361
+ show_weights: bool = True,
362
+ null_value: float | None = None,
363
+ log_scale: bool | None = None,
364
+ ) -> Axes:
365
+ """Draw a Matplotlib forest plot without calling ``show()``.
366
+
367
+ Matplotlib is an optional dependency. Install ``PyMetaAnalysis[plot]``
368
+ before calling this method.
369
+ """
370
+
371
+ from .plotting import forest_plot
372
+
373
+ return forest_plot(
374
+ self,
375
+ ax=ax,
376
+ effect_label=effect_label,
377
+ pooled_label=pooled_label,
378
+ show_prediction_interval=show_prediction_interval,
379
+ show_weights=show_weights,
380
+ null_value=null_value,
381
+ log_scale=log_scale,
382
+ )
383
+
384
+ def funnel(
385
+ self,
386
+ *,
387
+ ax: Axes | None = None,
388
+ effect_label: str | None = None,
389
+ confidence_level: float | None = None,
390
+ show_pseudo_confidence_interval: bool = True,
391
+ warn_on_few_studies: bool = True,
392
+ log_scale: bool | None = None,
393
+ ) -> Axes:
394
+ """Draw a standard-error funnel plot without calling ``show()``.
395
+
396
+ Matplotlib is an optional dependency. Install ``PyMetaAnalysis[plot]``
397
+ before calling this method. Funnel asymmetry is a diagnostic for
398
+ possible small-study effects and does not by itself establish
399
+ publication bias.
400
+ """
401
+
402
+ from .plotting import funnel_plot
403
+
404
+ return funnel_plot(
405
+ self,
406
+ ax=ax,
407
+ effect_label=effect_label,
408
+ confidence_level=confidence_level,
409
+ show_pseudo_confidence_interval=show_pseudo_confidence_interval,
410
+ warn_on_few_studies=warn_on_few_studies,
411
+ log_scale=log_scale,
412
+ )
413
+
414
+
415
+ @dataclass(frozen=True, slots=True)
416
+ class SubgroupMetaAnalysisResult:
417
+ """Results for independent study subgroups and their formal comparison."""
418
+
419
+ groups: Mapping[Hashable, MetaAnalysisResult]
420
+ overall: MetaAnalysisResult
421
+ q_between: float
422
+ q_between_df: int
423
+ q_between_pvalue: float
424
+ i2_between: float
425
+ method: SubgroupMethodConfig
426
+ warnings: tuple[str, ...]
427
+ _study_results: pd.DataFrame = field(repr=False, compare=False)
428
+
429
+ def __post_init__(self) -> None:
430
+ """Defensively freeze the group mapping and combined study table."""
431
+
432
+ object.__setattr__(self, "groups", MappingProxyType(dict(self.groups)))
433
+ object.__setattr__(self, "_study_results", self._study_results.copy(deep=True))
434
+
435
+ @property
436
+ def study_results(self) -> pd.DataFrame:
437
+ """Return all rows with their subgroup labels and overall fit metadata."""
438
+
439
+ return self._study_results.copy(deep=True)
440
+
441
+ @property
442
+ def excluded_studies(self) -> pd.DataFrame:
443
+ """Return rows excluded from the overall and subgroup fits."""
444
+
445
+ studies = self._study_results
446
+ return studies.loc[~studies["included"]].copy(deep=True)
447
+
448
+ def summary(self) -> SubgroupMetaAnalysisSummary:
449
+ """Return a printable and machine-readable subgroup summary."""
450
+
451
+ return SubgroupMetaAnalysisSummary(self)
452
+
453
+ def method_details(self) -> str:
454
+ """Return a Methods-style description including subgroup assumptions."""
455
+
456
+ from .reporting import subgroup_method_details
457
+
458
+ return subgroup_method_details(self)
459
+
460
+ def report(self, *, include_studies: bool = True) -> ResultReport:
461
+ """Build a structured subgroup report with JSON and Markdown forms."""
462
+
463
+ from .reporting import build_subgroup_report
464
+
465
+ return build_subgroup_report(self, include_studies=include_studies)
466
+
467
+ def to_dataframe(self) -> pd.DataFrame:
468
+ """Return the combined row-level subgroup table."""
469
+
470
+ return self.study_results
471
+
472
+ def leave_one_out(self) -> SubgroupLeaveOneOutResult:
473
+ """Run leave-one-out analyses for each subgroup and the overall model."""
474
+
475
+ from .sensitivity import subgroup_leave_one_out
476
+
477
+ return subgroup_leave_one_out(self)
478
+
479
+ def cumulative(
480
+ self,
481
+ *,
482
+ order: ColumnOrArray | None = None,
483
+ ascending: bool = True,
484
+ collapse: bool = False,
485
+ ) -> SubgroupCumulativeMetaAnalysisResult:
486
+ """Run cumulative analyses for each subgroup and the overall model."""
487
+
488
+ from .sensitivity import subgroup_cumulative
489
+
490
+ return subgroup_cumulative(
491
+ self,
492
+ order=order,
493
+ ascending=ascending,
494
+ collapse=collapse,
495
+ )
496
+
497
+ def forest(
498
+ self,
499
+ *,
500
+ ax: Axes | None = None,
501
+ effect_label: str | None = None,
502
+ show_prediction_interval: bool = True,
503
+ show_weights: bool = True,
504
+ null_value: float | None = None,
505
+ log_scale: bool | None = None,
506
+ ) -> Axes:
507
+ """Draw a subgroup forest plot without calling ``show()``."""
508
+
509
+ from .plotting import subgroup_forest_plot
510
+
511
+ return subgroup_forest_plot(
512
+ self,
513
+ ax=ax,
514
+ effect_label=effect_label,
515
+ show_prediction_interval=show_prediction_interval,
516
+ show_weights=show_weights,
517
+ null_value=null_value,
518
+ log_scale=log_scale,
519
+ )