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,62 @@
1
+ """Pandas-first tools for auditable meta-analysis workflows."""
2
+
3
+ from ._version import __version__
4
+ from .api import meta_analysis
5
+ from .binary_api import meta_binary
6
+ from .config import MethodConfig, SubgroupMethodConfig
7
+ from .continuous_api import meta_continuous
8
+ from .exceptions import (
9
+ ConvergenceError,
10
+ InsufficientStudiesError,
11
+ InvalidStudyDataError,
12
+ MetaAnalysisError,
13
+ UnsupportedMethodError,
14
+ )
15
+ from .provenance import (
16
+ AnalysisProvenance,
17
+ InputFieldProvenance,
18
+ TransformationRecord,
19
+ )
20
+ from .reporting import ResultReport
21
+ from .results import (
22
+ FitDiagnostics,
23
+ HeterogeneityResult,
24
+ MetaAnalysisResult,
25
+ MetaAnalysisSummary,
26
+ SubgroupMetaAnalysisResult,
27
+ SubgroupMetaAnalysisSummary,
28
+ )
29
+ from .sensitivity import (
30
+ CumulativeMetaAnalysisResult,
31
+ LeaveOneOutResult,
32
+ SubgroupCumulativeMetaAnalysisResult,
33
+ SubgroupLeaveOneOutResult,
34
+ )
35
+
36
+ __all__ = [
37
+ "AnalysisProvenance",
38
+ "ConvergenceError",
39
+ "CumulativeMetaAnalysisResult",
40
+ "FitDiagnostics",
41
+ "HeterogeneityResult",
42
+ "InsufficientStudiesError",
43
+ "InputFieldProvenance",
44
+ "InvalidStudyDataError",
45
+ "LeaveOneOutResult",
46
+ "MetaAnalysisError",
47
+ "MetaAnalysisResult",
48
+ "MetaAnalysisSummary",
49
+ "MethodConfig",
50
+ "ResultReport",
51
+ "SubgroupMetaAnalysisResult",
52
+ "SubgroupMetaAnalysisSummary",
53
+ "SubgroupCumulativeMetaAnalysisResult",
54
+ "SubgroupLeaveOneOutResult",
55
+ "SubgroupMethodConfig",
56
+ "TransformationRecord",
57
+ "UnsupportedMethodError",
58
+ "meta_analysis",
59
+ "meta_binary",
60
+ "meta_continuous",
61
+ "__version__",
62
+ ]
@@ -0,0 +1,3 @@
1
+ """Single source of truth for the package version."""
2
+
3
+ __version__ = "0.1.0"
meta_analyze/api.py ADDED
@@ -0,0 +1,330 @@
1
+ """High-level public analysis functions."""
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 .config import MethodConfig
12
+ from .data import ColumnOrArray, MissingPolicy, normalize_studies
13
+ from .estimators import fit_inverse_variance
14
+ from .exceptions import InvalidStudyDataError, UnsupportedMethodError
15
+ from .heterogeneity import classical_heterogeneity, tau2_inconsistency
16
+ from .provenance import add_input_field, build_analysis_provenance
17
+ from .results import (
18
+ FitDiagnostics,
19
+ HeterogeneityResult,
20
+ MetaAnalysisResult,
21
+ SubgroupMetaAnalysisResult,
22
+ )
23
+ from .subgroups import fit_subgroup_analysis
24
+
25
+
26
+ def _normalize_model(model: str) -> str:
27
+ normalized = model.lower().replace("-", "_")
28
+ if normalized in {"common", "common_effect", "fixed", "fixed_effect"}:
29
+ return "common"
30
+ if normalized in {"random", "random_effects", "random_effect"}:
31
+ return "random"
32
+ raise UnsupportedMethodError(
33
+ f"Unsupported model={model!r}; expected 'common' or 'random'."
34
+ )
35
+
36
+
37
+ def _normalize_ci_method(ci_method: str) -> str:
38
+ normalized = ci_method.lower().replace("-", "_")
39
+ aliases = {
40
+ "classic": "normal",
41
+ "z": "normal",
42
+ "hksj": "hartung_knapp",
43
+ "hk": "hartung_knapp",
44
+ "adhoc": "hartung_knapp_adhoc",
45
+ "hksj_adhoc": "hartung_knapp_adhoc",
46
+ }
47
+ return aliases.get(normalized, normalized)
48
+
49
+
50
+ def _validate_analysis_controls(
51
+ *, confidence_level: float, atol: float, max_iter: int
52
+ ) -> tuple[float, float, int]:
53
+ if not isinstance(confidence_level, (int, float)) or not (
54
+ 0.0 < float(confidence_level) < 1.0
55
+ ):
56
+ raise InvalidStudyDataError("confidence_level must be between 0 and 1.")
57
+ if not isinstance(max_iter, int) or isinstance(max_iter, bool) or max_iter < 1:
58
+ raise InvalidStudyDataError("max_iter must be a positive integer.")
59
+ if not isinstance(atol, (int, float)) or not np.isfinite(atol) or atol <= 0.0:
60
+ raise InvalidStudyDataError("atol must be finite and strictly positive.")
61
+ return float(confidence_level), float(atol), max_iter
62
+
63
+
64
+ def _fit_meta_analysis_single(
65
+ data: pd.DataFrame | None = None,
66
+ *,
67
+ effect: ColumnOrArray,
68
+ variance: ColumnOrArray,
69
+ study: ColumnOrArray | None = None,
70
+ model: str = "random",
71
+ tau2_method: str = "REML",
72
+ ci_method: str = "normal",
73
+ confidence_level: float = 0.95,
74
+ missing: MissingPolicy = "raise",
75
+ atol: float = 1e-10,
76
+ max_iter: int = 1000,
77
+ ) -> MetaAnalysisResult:
78
+ """Fit a generic inverse-variance meta-analysis.
79
+
80
+ Parameters
81
+ ----------
82
+ data:
83
+ Optional pandas DataFrame. String-valued input arguments select columns
84
+ from this frame.
85
+ effect, variance:
86
+ A DataFrame column name or one-dimensional array-like containing study
87
+ effects and strictly positive sampling variances.
88
+ study:
89
+ Optional study label column/array. DataFrame input defaults to its index;
90
+ array-only input defaults to integer row labels.
91
+ model:
92
+ ``"common"`` (``"fixed"`` is an alias) or ``"random"``.
93
+ tau2_method:
94
+ ``"REML"`` (default), ``"PM"``, or ``"DL"`` for random-effects models.
95
+ ci_method:
96
+ ``"normal"``, ``"hartung_knapp"``, or
97
+ ``"hartung_knapp_adhoc"``.
98
+ confidence_level:
99
+ Confidence level strictly between zero and one.
100
+ missing:
101
+ ``"raise"`` (default) or ``"drop"``. Dropped studies remain visible in
102
+ the result with a structured exclusion reason.
103
+ atol, max_iter:
104
+ Numerical controls for iterative tau-squared estimators.
105
+ """
106
+
107
+ confidence_level, atol, max_iter = _validate_analysis_controls(
108
+ confidence_level=confidence_level,
109
+ atol=atol,
110
+ max_iter=max_iter,
111
+ )
112
+
113
+ normalized_model = _normalize_model(model)
114
+ normalized_ci = _normalize_ci_method(ci_method)
115
+ normalized_tau2 = tau2_method.upper().replace("-", "_")
116
+
117
+ studies = normalize_studies(
118
+ data=data,
119
+ effect=effect,
120
+ variance=variance,
121
+ study=study,
122
+ missing=missing,
123
+ )
124
+ included_effect = studies.included_effect
125
+ included_variance = studies.included_variance
126
+
127
+ fit = fit_inverse_variance(
128
+ included_effect,
129
+ included_variance,
130
+ model=normalized_model,
131
+ tau2_method=normalized_tau2,
132
+ ci_method=normalized_ci,
133
+ confidence_level=confidence_level,
134
+ atol=atol,
135
+ max_iter=max_iter,
136
+ )
137
+
138
+ q, q_df, q_pvalue, i2, h2 = classical_heterogeneity(
139
+ included_effect, included_variance
140
+ )
141
+ tau2_value = 0.0 if fit.tau2 is None else fit.tau2.value
142
+ i2_method = "q_based"
143
+ if normalized_model == "random":
144
+ i2, h2 = tau2_inconsistency(included_variance, tau2_value)
145
+ i2_method = "tau2_typical_variance"
146
+ heterogeneity = HeterogeneityResult(q, q_df, q_pvalue, i2, h2, i2_method)
147
+
148
+ row_count = len(studies.row_id)
149
+ raw_weights = np.full(row_count, np.nan, dtype=np.float64)
150
+ normalized_weights = np.full(row_count, np.nan, dtype=np.float64)
151
+ raw_weights[studies.included] = fit.weights
152
+ normalized_weights[studies.included] = fit.normalized_weights
153
+ study_results = pd.DataFrame(
154
+ {
155
+ "row_id": studies.row_id,
156
+ "study": studies.study,
157
+ "effect": studies.effect,
158
+ "variance": studies.variance,
159
+ "standard_error": np.sqrt(studies.variance),
160
+ "included": studies.included,
161
+ "exclusion_reason": pd.Series(
162
+ studies.exclusion_reason, dtype=object, copy=True
163
+ ),
164
+ "weight": raw_weights,
165
+ "normalized_weight": normalized_weights,
166
+ }
167
+ )
168
+
169
+ warnings = list(fit.warnings)
170
+ excluded_count = int(np.count_nonzero(~studies.included))
171
+ if excluded_count:
172
+ warnings.append(
173
+ f"Excluded {excluded_count} study row(s) under missing={missing!r}."
174
+ )
175
+
176
+ diagnostics = FitDiagnostics(
177
+ converged=True if fit.tau2 is None else fit.tau2.converged,
178
+ iterations=0 if fit.tau2 is None else fit.tau2.iterations,
179
+ tau2_at_boundary=None if fit.tau2 is None else fit.tau2.boundary,
180
+ )
181
+ method = MethodConfig(
182
+ model=normalized_model,
183
+ pooling_method="inverse_variance",
184
+ tau2_method=None if normalized_model == "common" else normalized_tau2,
185
+ ci_method=normalized_ci,
186
+ confidence_level=confidence_level,
187
+ prediction_interval_method="HTS" if normalized_model == "random" else None,
188
+ missing=missing,
189
+ atol=atol,
190
+ max_iter=max_iter,
191
+ options=(),
192
+ )
193
+ provenance = build_analysis_provenance(
194
+ analysis_type="generic",
195
+ data=data,
196
+ inputs=(("effect", effect), ("variance", variance)),
197
+ study=study,
198
+ included=studies.included,
199
+ )
200
+
201
+ return MetaAnalysisResult(
202
+ estimate=fit.estimate,
203
+ standard_error=fit.standard_error,
204
+ ci_low=fit.ci_low,
205
+ ci_high=fit.ci_high,
206
+ prediction_interval=fit.prediction_interval,
207
+ tau2=tau2_value,
208
+ heterogeneity=heterogeneity,
209
+ k=len(included_effect),
210
+ model=normalized_model,
211
+ measure="GENERIC",
212
+ effect_scale="identity",
213
+ display_scale="identity",
214
+ method=method,
215
+ diagnostics=diagnostics,
216
+ provenance=provenance,
217
+ warnings=tuple(warnings),
218
+ _study_results=study_results,
219
+ _source_data=data,
220
+ )
221
+
222
+
223
+ @overload
224
+ def meta_analysis(
225
+ data: pd.DataFrame | None = None,
226
+ *,
227
+ effect: ColumnOrArray,
228
+ variance: ColumnOrArray,
229
+ study: ColumnOrArray | None = None,
230
+ subgroup: None = None,
231
+ model: str = "random",
232
+ tau2_method: str = "REML",
233
+ ci_method: str = "normal",
234
+ confidence_level: float = 0.95,
235
+ missing: MissingPolicy = "raise",
236
+ atol: float = 1e-10,
237
+ max_iter: int = 1000,
238
+ ) -> MetaAnalysisResult: ...
239
+
240
+
241
+ @overload
242
+ def meta_analysis(
243
+ data: pd.DataFrame | None = None,
244
+ *,
245
+ effect: ColumnOrArray,
246
+ variance: ColumnOrArray,
247
+ study: ColumnOrArray | None = None,
248
+ subgroup: ColumnOrArray,
249
+ model: str = "random",
250
+ tau2_method: str = "REML",
251
+ ci_method: str = "normal",
252
+ confidence_level: float = 0.95,
253
+ missing: MissingPolicy = "raise",
254
+ atol: float = 1e-10,
255
+ max_iter: int = 1000,
256
+ ) -> SubgroupMetaAnalysisResult: ...
257
+
258
+
259
+ def meta_analysis(
260
+ data: pd.DataFrame | None = None,
261
+ *,
262
+ effect: ColumnOrArray,
263
+ variance: ColumnOrArray,
264
+ study: ColumnOrArray | None = None,
265
+ subgroup: ColumnOrArray | None = None,
266
+ model: str = "random",
267
+ tau2_method: str = "REML",
268
+ ci_method: str = "normal",
269
+ confidence_level: float = 0.95,
270
+ missing: MissingPolicy = "raise",
271
+ atol: float = 1e-10,
272
+ max_iter: int = 1000,
273
+ ) -> MetaAnalysisResult | SubgroupMetaAnalysisResult:
274
+ """Fit a generic inverse-variance meta-analysis, optionally by subgroup.
275
+
276
+ ``effect`` and ``variance`` accept DataFrame column names or one-dimensional
277
+ array-like values. Sampling variances must be finite and strictly positive.
278
+ The default is a REML random-effects model with a normal confidence
279
+ interval. ``subgroup`` returns :class:`SubgroupMetaAnalysisResult` when
280
+ supplied; otherwise the return value is :class:`MetaAnalysisResult`.
281
+ Missing subgroup labels are rejected explicitly.
282
+ """
283
+
284
+ overall = _fit_meta_analysis_single(
285
+ data,
286
+ effect=effect,
287
+ variance=variance,
288
+ study=study,
289
+ model=model,
290
+ tau2_method=tau2_method,
291
+ ci_method=ci_method,
292
+ confidence_level=confidence_level,
293
+ missing=missing,
294
+ atol=atol,
295
+ max_iter=max_iter,
296
+ )
297
+ if subgroup is None:
298
+ return overall
299
+
300
+ overall = replace(
301
+ overall,
302
+ provenance=add_input_field(
303
+ overall.provenance,
304
+ role="subgroup",
305
+ value=subgroup,
306
+ data=data,
307
+ ),
308
+ )
309
+
310
+ def fit_group(positions: np.ndarray) -> MetaAnalysisResult:
311
+ rows = overall.study_results.iloc[positions]
312
+ return _fit_meta_analysis_single(
313
+ effect=rows["effect"].to_numpy(dtype=np.float64, copy=True),
314
+ variance=rows["variance"].to_numpy(dtype=np.float64, copy=True),
315
+ study=rows["study"].to_numpy(dtype=object, copy=True),
316
+ model=model,
317
+ tau2_method=tau2_method,
318
+ ci_method=ci_method,
319
+ confidence_level=confidence_level,
320
+ missing=missing,
321
+ atol=atol,
322
+ max_iter=max_iter,
323
+ )
324
+
325
+ return fit_subgroup_analysis(
326
+ data=data,
327
+ subgroup=subgroup,
328
+ overall=overall,
329
+ fit_group=fit_group,
330
+ )