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,353 @@
1
+ """High-level API for two-group continuous 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 _normalize_ci_method, _normalize_model, _validate_analysis_controls
12
+ from .config import MethodConfig, MethodOptionValue
13
+ from .data import ColumnOrArray, MissingPolicy
14
+ from .effect_sizes.continuous import (
15
+ calculate_continuous_effects,
16
+ normalize_continuous_studies,
17
+ )
18
+ from .estimators import fit_inverse_variance
19
+ from .heterogeneity import classical_heterogeneity, tau2_inconsistency
20
+ from .provenance import (
21
+ TransformationRecord,
22
+ add_input_field,
23
+ build_analysis_provenance,
24
+ )
25
+ from .results import (
26
+ FitDiagnostics,
27
+ HeterogeneityResult,
28
+ MetaAnalysisResult,
29
+ SubgroupMetaAnalysisResult,
30
+ )
31
+ from .subgroups import fit_subgroup_analysis
32
+
33
+
34
+ def _fit_meta_continuous_single(
35
+ data: pd.DataFrame | None = None,
36
+ *,
37
+ mean_treat: ColumnOrArray,
38
+ sd_treat: ColumnOrArray,
39
+ n_treat: ColumnOrArray,
40
+ mean_control: ColumnOrArray,
41
+ sd_control: ColumnOrArray,
42
+ n_control: ColumnOrArray,
43
+ study: ColumnOrArray | None = None,
44
+ measure: str = "MD",
45
+ model: str = "random",
46
+ tau2_method: str = "REML",
47
+ ci_method: str = "normal",
48
+ confidence_level: float = 0.95,
49
+ smd_variance: str = "LS",
50
+ missing: MissingPolicy = "raise",
51
+ atol: float = 1e-10,
52
+ max_iter: int = 1000,
53
+ ) -> MetaAnalysisResult:
54
+ """Pool mean differences or Hedges' g from two independent groups.
55
+
56
+ ``measure="MD"`` uses the unpooled sampling variance
57
+ ``sd_treat**2 / n_treat + sd_control**2 / n_control``. ``measure="SMD"``
58
+ uses a pooled SD, the exact Hedges correction, and the ``LS`` sampling
59
+ variance convention used by ``metafor::escalc(measure="SMD")``.
60
+ """
61
+
62
+ confidence_level, atol, max_iter = _validate_analysis_controls(
63
+ confidence_level=confidence_level,
64
+ atol=atol,
65
+ max_iter=max_iter,
66
+ )
67
+ normalized_model = _normalize_model(model)
68
+ normalized_ci = _normalize_ci_method(ci_method)
69
+ normalized_tau2 = tau2_method.upper().replace("-", "_")
70
+
71
+ studies = normalize_continuous_studies(
72
+ data=data,
73
+ mean_treat=mean_treat,
74
+ sd_treat=sd_treat,
75
+ n_treat=n_treat,
76
+ mean_control=mean_control,
77
+ sd_control=sd_control,
78
+ n_control=n_control,
79
+ study=study,
80
+ missing=missing,
81
+ )
82
+ effects = calculate_continuous_effects(
83
+ studies,
84
+ measure=measure,
85
+ smd_variance=smd_variance,
86
+ )
87
+ included = studies.included
88
+ included_effect = effects.included_effect
89
+ included_variance = effects.included_variance
90
+
91
+ fit = fit_inverse_variance(
92
+ included_effect,
93
+ included_variance,
94
+ model=normalized_model,
95
+ tau2_method=normalized_tau2,
96
+ ci_method=normalized_ci,
97
+ confidence_level=confidence_level,
98
+ atol=atol,
99
+ max_iter=max_iter,
100
+ )
101
+ q, q_df, q_pvalue, i2, h2 = classical_heterogeneity(
102
+ included_effect, included_variance
103
+ )
104
+ tau2_value = 0.0 if fit.tau2 is None else fit.tau2.value
105
+ i2_method = "q_based"
106
+ if normalized_model == "random":
107
+ i2, h2 = tau2_inconsistency(included_variance, tau2_value)
108
+ i2_method = "tau2_typical_variance"
109
+ heterogeneity = HeterogeneityResult(q, q_df, q_pvalue, i2, h2, i2_method)
110
+
111
+ row_count = len(included)
112
+ raw_weights = np.full(row_count, np.nan, dtype=np.float64)
113
+ normalized_weights = np.full(row_count, np.nan, dtype=np.float64)
114
+ raw_weights[included] = fit.weights
115
+ normalized_weights[included] = fit.normalized_weights
116
+ study_results = pd.DataFrame(
117
+ {
118
+ "row_id": studies.row_id,
119
+ "study": studies.study,
120
+ "mean_treat": studies.mean_treat,
121
+ "sd_treat": studies.sd_treat,
122
+ "n_treat": studies.n_treat,
123
+ "mean_control": studies.mean_control,
124
+ "sd_control": studies.sd_control,
125
+ "n_control": studies.n_control,
126
+ "effect": effects.effect,
127
+ "effect_display": effects.effect,
128
+ "variance": effects.variance,
129
+ "standard_error": np.sqrt(effects.variance),
130
+ "pooled_sd": effects.pooled_sd,
131
+ "cohen_d": effects.cohen_d,
132
+ "smd_correction_factor": effects.correction_factor,
133
+ "included": included,
134
+ "exclusion_reason": pd.Series(
135
+ studies.exclusion_reason, dtype=object, copy=True
136
+ ),
137
+ "weight": raw_weights,
138
+ "normalized_weight": normalized_weights,
139
+ }
140
+ )
141
+
142
+ warnings = list(fit.warnings)
143
+ excluded_count = int(np.count_nonzero(~included))
144
+ if excluded_count:
145
+ warnings.append(
146
+ f"Excluded {excluded_count} study row(s) under missing={missing!r}."
147
+ )
148
+
149
+ options: tuple[tuple[str, MethodOptionValue], ...]
150
+ if effects.measure == "SMD":
151
+ options = (
152
+ ("effect_estimator", "hedges_g_exact"),
153
+ ("standardizer", "pooled_sd"),
154
+ ("smd_variance", "LS"),
155
+ )
156
+ else:
157
+ options = (("sampling_variance", "unpooled"),)
158
+ method = MethodConfig(
159
+ model=normalized_model,
160
+ pooling_method="inverse_variance",
161
+ tau2_method=None if normalized_model == "common" else normalized_tau2,
162
+ ci_method=normalized_ci,
163
+ confidence_level=confidence_level,
164
+ prediction_interval_method="HTS" if normalized_model == "random" else None,
165
+ missing=missing,
166
+ atol=atol,
167
+ max_iter=max_iter,
168
+ options=options,
169
+ )
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
+ provenance = build_analysis_provenance(
176
+ analysis_type="continuous",
177
+ data=data,
178
+ inputs=(
179
+ ("mean_treat", mean_treat),
180
+ ("sd_treat", sd_treat),
181
+ ("n_treat", n_treat),
182
+ ("mean_control", mean_control),
183
+ ("sd_control", sd_control),
184
+ ("n_control", n_control),
185
+ ),
186
+ study=study,
187
+ included=included,
188
+ transformations=(
189
+ TransformationRecord(
190
+ name="continuous_effect_size",
191
+ parameters=(("measure", effects.measure), *options),
192
+ affected_rows=tuple(int(row) for row in np.flatnonzero(included)),
193
+ ),
194
+ ),
195
+ )
196
+ return MetaAnalysisResult(
197
+ estimate=fit.estimate,
198
+ standard_error=fit.standard_error,
199
+ ci_low=fit.ci_low,
200
+ ci_high=fit.ci_high,
201
+ prediction_interval=fit.prediction_interval,
202
+ tau2=tau2_value,
203
+ heterogeneity=heterogeneity,
204
+ k=len(included_effect),
205
+ model=normalized_model,
206
+ measure=effects.measure,
207
+ effect_scale=effects.effect_scale,
208
+ display_scale=effects.display_scale,
209
+ method=method,
210
+ diagnostics=diagnostics,
211
+ provenance=provenance,
212
+ warnings=tuple(warnings),
213
+ _study_results=study_results,
214
+ _source_data=data,
215
+ )
216
+
217
+
218
+ @overload
219
+ def meta_continuous(
220
+ data: pd.DataFrame | None = None,
221
+ *,
222
+ mean_treat: ColumnOrArray,
223
+ sd_treat: ColumnOrArray,
224
+ n_treat: ColumnOrArray,
225
+ mean_control: ColumnOrArray,
226
+ sd_control: ColumnOrArray,
227
+ n_control: ColumnOrArray,
228
+ study: ColumnOrArray | None = None,
229
+ subgroup: None = None,
230
+ measure: str = "MD",
231
+ model: str = "random",
232
+ tau2_method: str = "REML",
233
+ ci_method: str = "normal",
234
+ confidence_level: float = 0.95,
235
+ smd_variance: str = "LS",
236
+ missing: MissingPolicy = "raise",
237
+ atol: float = 1e-10,
238
+ max_iter: int = 1000,
239
+ ) -> MetaAnalysisResult: ...
240
+
241
+
242
+ @overload
243
+ def meta_continuous(
244
+ data: pd.DataFrame | None = None,
245
+ *,
246
+ mean_treat: ColumnOrArray,
247
+ sd_treat: ColumnOrArray,
248
+ n_treat: ColumnOrArray,
249
+ mean_control: ColumnOrArray,
250
+ sd_control: ColumnOrArray,
251
+ n_control: ColumnOrArray,
252
+ study: ColumnOrArray | None = None,
253
+ subgroup: ColumnOrArray,
254
+ measure: str = "MD",
255
+ model: str = "random",
256
+ tau2_method: str = "REML",
257
+ ci_method: str = "normal",
258
+ confidence_level: float = 0.95,
259
+ smd_variance: str = "LS",
260
+ missing: MissingPolicy = "raise",
261
+ atol: float = 1e-10,
262
+ max_iter: int = 1000,
263
+ ) -> SubgroupMetaAnalysisResult: ...
264
+
265
+
266
+ def meta_continuous(
267
+ data: pd.DataFrame | None = None,
268
+ *,
269
+ mean_treat: ColumnOrArray,
270
+ sd_treat: ColumnOrArray,
271
+ n_treat: ColumnOrArray,
272
+ mean_control: ColumnOrArray,
273
+ sd_control: ColumnOrArray,
274
+ n_control: ColumnOrArray,
275
+ study: ColumnOrArray | None = None,
276
+ subgroup: ColumnOrArray | None = None,
277
+ measure: str = "MD",
278
+ model: str = "random",
279
+ tau2_method: str = "REML",
280
+ ci_method: str = "normal",
281
+ confidence_level: float = 0.95,
282
+ smd_variance: str = "LS",
283
+ missing: MissingPolicy = "raise",
284
+ atol: float = 1e-10,
285
+ max_iter: int = 1000,
286
+ ) -> MetaAnalysisResult | SubgroupMetaAnalysisResult:
287
+ """Pool two-group continuous outcomes, optionally by subgroup.
288
+
289
+ Group means, sample standard deviations, and sample sizes accept DataFrame
290
+ column names or one-dimensional array-like values. The default is a REML
291
+ random-effects mean difference. ``measure="SMD"`` calculates exact-
292
+ corrected Hedges' g with the LS sampling-variance convention.
293
+ """
294
+
295
+ overall = _fit_meta_continuous_single(
296
+ data,
297
+ mean_treat=mean_treat,
298
+ sd_treat=sd_treat,
299
+ n_treat=n_treat,
300
+ mean_control=mean_control,
301
+ sd_control=sd_control,
302
+ n_control=n_control,
303
+ study=study,
304
+ measure=measure,
305
+ model=model,
306
+ tau2_method=tau2_method,
307
+ ci_method=ci_method,
308
+ confidence_level=confidence_level,
309
+ smd_variance=smd_variance,
310
+ missing=missing,
311
+ atol=atol,
312
+ max_iter=max_iter,
313
+ )
314
+ if subgroup is None:
315
+ return overall
316
+
317
+ overall = replace(
318
+ overall,
319
+ provenance=add_input_field(
320
+ overall.provenance,
321
+ role="subgroup",
322
+ value=subgroup,
323
+ data=data,
324
+ ),
325
+ )
326
+
327
+ def fit_group(positions: np.ndarray) -> MetaAnalysisResult:
328
+ rows = overall.study_results.iloc[positions]
329
+ return _fit_meta_continuous_single(
330
+ mean_treat=rows["mean_treat"].to_numpy(dtype=np.float64, copy=True),
331
+ sd_treat=rows["sd_treat"].to_numpy(dtype=np.float64, copy=True),
332
+ n_treat=rows["n_treat"].to_numpy(dtype=np.float64, copy=True),
333
+ mean_control=rows["mean_control"].to_numpy(dtype=np.float64, copy=True),
334
+ sd_control=rows["sd_control"].to_numpy(dtype=np.float64, copy=True),
335
+ n_control=rows["n_control"].to_numpy(dtype=np.float64, copy=True),
336
+ study=rows["study"].to_numpy(dtype=object, copy=True),
337
+ measure=measure,
338
+ model=model,
339
+ tau2_method=tau2_method,
340
+ ci_method=ci_method,
341
+ confidence_level=confidence_level,
342
+ smd_variance=smd_variance,
343
+ missing=missing,
344
+ atol=atol,
345
+ max_iter=max_iter,
346
+ )
347
+
348
+ return fit_subgroup_analysis(
349
+ data=data,
350
+ subgroup=subgroup,
351
+ overall=overall,
352
+ fit_group=fit_group,
353
+ )
meta_analyze/data.py ADDED
@@ -0,0 +1,181 @@
1
+ """Input normalization at the pandas/NumPy boundary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Literal, TypeAlias
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ from numpy.typing import ArrayLike, NDArray
11
+
12
+ from .exceptions import InvalidStudyDataError
13
+
14
+ ColumnOrArray: TypeAlias = str | ArrayLike
15
+ MissingPolicy: TypeAlias = Literal["raise", "drop"]
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class NormalizedStudies:
20
+ """Validated study vectors plus row-level inclusion metadata."""
21
+
22
+ row_id: NDArray[np.int64]
23
+ study: NDArray[np.object_]
24
+ effect: NDArray[np.float64]
25
+ variance: NDArray[np.float64]
26
+ included: NDArray[np.bool_]
27
+ exclusion_reason: NDArray[np.object_]
28
+
29
+ @property
30
+ def included_effect(self) -> NDArray[np.float64]:
31
+ return self.effect[self.included]
32
+
33
+ @property
34
+ def included_variance(self) -> NDArray[np.float64]:
35
+ return self.variance[self.included]
36
+
37
+
38
+ def _resolve_vector(
39
+ value: ColumnOrArray,
40
+ *,
41
+ data: pd.DataFrame | None,
42
+ name: str,
43
+ ) -> NDArray[Any]:
44
+ if isinstance(value, str):
45
+ if data is None:
46
+ raise InvalidStudyDataError(
47
+ f"{name}={value!r} is a column name, but no DataFrame was provided."
48
+ )
49
+ if value not in data.columns:
50
+ raise InvalidStudyDataError(
51
+ f"Column {value!r}, specified by {name}, is not present in data."
52
+ )
53
+ array = data[value].to_numpy(copy=True)
54
+ else:
55
+ if isinstance(value, (str, bytes)):
56
+ raise InvalidStudyDataError(
57
+ f"{name} must be a column name or 1D array-like."
58
+ )
59
+ array = np.asarray(value)
60
+
61
+ if array.ndim != 1:
62
+ raise InvalidStudyDataError(
63
+ f"{name} must be one-dimensional, got shape {array.shape}."
64
+ )
65
+ return array
66
+
67
+
68
+ def _study_labels(
69
+ study: ColumnOrArray | None,
70
+ *,
71
+ data: pd.DataFrame | None,
72
+ length: int,
73
+ ) -> NDArray[np.object_]:
74
+ if study is None:
75
+ labels: NDArray[Any]
76
+ if data is not None:
77
+ labels = data.index.to_numpy(copy=True)
78
+ else:
79
+ labels = np.arange(length, dtype=np.int64)
80
+ else:
81
+ labels = _resolve_vector(study, data=data, name="study")
82
+
83
+ if len(labels) != length:
84
+ raise InvalidStudyDataError(
85
+ f"study has length {len(labels)}, but effect and variance have "
86
+ f"length {length}."
87
+ )
88
+ return np.asarray(labels, dtype=object)
89
+
90
+
91
+ def normalize_studies(
92
+ *,
93
+ data: pd.DataFrame | None,
94
+ effect: ColumnOrArray,
95
+ variance: ColumnOrArray,
96
+ study: ColumnOrArray | None,
97
+ missing: MissingPolicy,
98
+ ) -> NormalizedStudies:
99
+ """Resolve column/array arguments and validate generic effect data."""
100
+
101
+ if data is not None and not isinstance(data, pd.DataFrame):
102
+ raise InvalidStudyDataError("data must be a pandas DataFrame or None.")
103
+ if missing not in {"raise", "drop"}:
104
+ raise InvalidStudyDataError("missing must be either 'raise' or 'drop'.")
105
+
106
+ raw_effect = _resolve_vector(effect, data=data, name="effect")
107
+ raw_variance = _resolve_vector(variance, data=data, name="variance")
108
+ if len(raw_effect) != len(raw_variance):
109
+ raise InvalidStudyDataError(
110
+ "effect and variance must have the same length; "
111
+ f"got {len(raw_effect)} and {len(raw_variance)}."
112
+ )
113
+ if data is not None and len(data) != len(raw_effect):
114
+ raise InvalidStudyDataError(
115
+ "Array-like inputs used with data must have exactly one value per "
116
+ "DataFrame row."
117
+ )
118
+
119
+ labels = _study_labels(study, data=data, length=len(raw_effect))
120
+
121
+ try:
122
+ effect_values = np.asarray(raw_effect, dtype=np.float64)
123
+ variance_values = np.asarray(raw_variance, dtype=np.float64)
124
+ except (TypeError, ValueError) as error:
125
+ raise InvalidStudyDataError(
126
+ "effect and variance must contain numeric values."
127
+ ) from error
128
+
129
+ effect_missing = pd.isna(effect_values)
130
+ variance_missing = pd.isna(variance_values)
131
+ any_missing = effect_missing | variance_missing
132
+ if np.any(any_missing) and missing == "raise":
133
+ rows = np.flatnonzero(any_missing).tolist()
134
+ raise InvalidStudyDataError(
135
+ f"Missing effect or variance values at row positions {rows}; "
136
+ "use missing='drop' to exclude them explicitly."
137
+ )
138
+
139
+ finite_effect = np.isfinite(effect_values) | effect_missing
140
+ finite_variance = np.isfinite(variance_values) | variance_missing
141
+ if not np.all(finite_effect):
142
+ rows = np.flatnonzero(~finite_effect).tolist()
143
+ raise InvalidStudyDataError(
144
+ f"Effect values must be finite; invalid rows: {rows}."
145
+ )
146
+ if not np.all(finite_variance):
147
+ rows = np.flatnonzero(~finite_variance).tolist()
148
+ raise InvalidStudyDataError(
149
+ f"Variance values must be finite; invalid rows: {rows}."
150
+ )
151
+
152
+ nonpositive_variance = (~variance_missing) & (variance_values <= 0.0)
153
+ if np.any(nonpositive_variance):
154
+ rows = np.flatnonzero(nonpositive_variance).tolist()
155
+ raise InvalidStudyDataError(
156
+ f"Sampling variances must be strictly positive; invalid rows: {rows}."
157
+ )
158
+
159
+ included = ~any_missing
160
+ reasons = np.full(len(effect_values), None, dtype=object)
161
+ for index in np.flatnonzero(any_missing):
162
+ if effect_missing[index] and variance_missing[index]:
163
+ reasons[index] = "missing effect and variance"
164
+ elif effect_missing[index]:
165
+ reasons[index] = "missing effect"
166
+ else:
167
+ reasons[index] = "missing variance"
168
+
169
+ if not np.any(included):
170
+ raise InvalidStudyDataError(
171
+ "No studies remain after applying the missing-value policy."
172
+ )
173
+
174
+ return NormalizedStudies(
175
+ row_id=np.arange(len(effect_values), dtype=np.int64),
176
+ study=labels,
177
+ effect=effect_values,
178
+ variance=variance_values,
179
+ included=included,
180
+ exclusion_reason=reasons,
181
+ )
@@ -0,0 +1,17 @@
1
+ """Study-level effect-size calculations."""
2
+
3
+ from .binary import BinaryEffectData, BinaryStudies, calculate_binary_effects
4
+ from .continuous import (
5
+ ContinuousEffectData,
6
+ ContinuousStudies,
7
+ calculate_continuous_effects,
8
+ )
9
+
10
+ __all__ = [
11
+ "BinaryEffectData",
12
+ "BinaryStudies",
13
+ "ContinuousEffectData",
14
+ "ContinuousStudies",
15
+ "calculate_binary_effects",
16
+ "calculate_continuous_effects",
17
+ ]