PyMetaAnalysis 0.2.1__py3-none-any.whl → 0.4.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.
meta_analyze/__init__.py CHANGED
@@ -3,8 +3,9 @@
3
3
  from ._version import __version__
4
4
  from .api import meta_analysis
5
5
  from .binary_api import meta_binary
6
- from .config import MethodConfig, SubgroupMethodConfig
6
+ from .config import MetaRegressionMethodConfig, MethodConfig, SubgroupMethodConfig
7
7
  from .continuous_api import meta_continuous
8
+ from .design_matrix import DesignInfo, ModeratorSpec
8
9
  from .exceptions import (
9
10
  ConvergenceError,
10
11
  InsufficientStudiesError,
@@ -17,6 +18,22 @@ from .provenance import (
17
18
  InputFieldProvenance,
18
19
  TransformationRecord,
19
20
  )
21
+ from .regression_api import meta_regression
22
+ from .regression_collinearity import MetaRegressionCollinearityResult
23
+ from .regression_contrasts import (
24
+ LinearContrastTestResult,
25
+ MetaRegressionContrastResult,
26
+ )
27
+ from .regression_results import (
28
+ MetaRegressionDiagnostics,
29
+ MetaRegressionResult,
30
+ MetaRegressionSummary,
31
+ ModeratorTestResult,
32
+ )
33
+ from .regression_sensitivity import (
34
+ MetaRegressionInfluenceResult,
35
+ MetaRegressionLeaveOneOutResult,
36
+ )
20
37
  from .reporting import ResultReport
21
38
  from .results import (
22
39
  FitDiagnostics,
@@ -37,16 +54,28 @@ __all__ = [
37
54
  "AnalysisProvenance",
38
55
  "ConvergenceError",
39
56
  "CumulativeMetaAnalysisResult",
57
+ "DesignInfo",
40
58
  "FitDiagnostics",
41
59
  "HeterogeneityResult",
42
60
  "InsufficientStudiesError",
43
61
  "InputFieldProvenance",
44
62
  "InvalidStudyDataError",
45
63
  "LeaveOneOutResult",
64
+ "LinearContrastTestResult",
46
65
  "MetaAnalysisError",
47
66
  "MetaAnalysisResult",
48
67
  "MetaAnalysisSummary",
68
+ "MetaRegressionCollinearityResult",
69
+ "MetaRegressionContrastResult",
70
+ "MetaRegressionDiagnostics",
71
+ "MetaRegressionInfluenceResult",
72
+ "MetaRegressionLeaveOneOutResult",
73
+ "MetaRegressionMethodConfig",
74
+ "MetaRegressionResult",
75
+ "MetaRegressionSummary",
49
76
  "MethodConfig",
77
+ "ModeratorSpec",
78
+ "ModeratorTestResult",
50
79
  "ResultReport",
51
80
  "SubgroupMetaAnalysisResult",
52
81
  "SubgroupMetaAnalysisSummary",
@@ -58,5 +87,6 @@ __all__ = [
58
87
  "meta_analysis",
59
88
  "meta_binary",
60
89
  "meta_continuous",
90
+ "meta_regression",
61
91
  "__version__",
62
92
  ]
meta_analyze/_version.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Single source of truth for the package version."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.4.0"
meta_analyze/config.py CHANGED
@@ -32,3 +32,21 @@ class SubgroupMethodConfig:
32
32
  tau2_strategy: str
33
33
  test_method: str
34
34
  subgroup_missing: str
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class MetaRegressionMethodConfig:
39
+ """The fully resolved assumptions used for a meta-regression."""
40
+
41
+ model: str
42
+ tau2_method: str | None
43
+ inference_method: str
44
+ confidence_level: float
45
+ intercept: bool
46
+ moderator_names: tuple[str, ...]
47
+ term_names: tuple[str, ...]
48
+ categorical_references: tuple[tuple[str, str], ...]
49
+ prediction_interval_method: str | None
50
+ missing: str
51
+ atol: float
52
+ max_iter: int
@@ -0,0 +1,477 @@
1
+ """Deterministic moderator normalization and design-matrix construction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Hashable, Mapping, Sequence
6
+ from dataclasses import dataclass
7
+ from typing import Any, Literal, TypeAlias
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+ from numpy.typing import NDArray
12
+
13
+ from .data import (
14
+ ColumnOrArray,
15
+ MissingPolicy,
16
+ _resolve_vector,
17
+ normalize_studies,
18
+ )
19
+ from .exceptions import InsufficientStudiesError, InvalidStudyDataError
20
+
21
+ ModeratorInput: TypeAlias = Sequence[str] | Mapping[str, ColumnOrArray]
22
+ CategoricalInput: TypeAlias = Mapping[str, Sequence[Hashable]]
23
+
24
+ _RESERVED_TERM_NAMES = frozenset(
25
+ {
26
+ "intercept",
27
+ "row_id",
28
+ "study",
29
+ "effect",
30
+ "variance",
31
+ "standard_error",
32
+ "included",
33
+ "exclusion_reason",
34
+ "fitted_value",
35
+ "residual",
36
+ "precision_weight",
37
+ "normalized_precision_weight",
38
+ "leverage",
39
+ }
40
+ )
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class ModeratorSpec:
45
+ """One moderator and its resolved numerical encoding."""
46
+
47
+ name: str
48
+ kind: Literal["numeric", "categorical"]
49
+ term_names: tuple[str, ...]
50
+ levels: tuple[Hashable, ...] = ()
51
+ reference: Hashable | None = None
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class DesignInfo:
56
+ """Replayable description of a fitted meta-regression design matrix."""
57
+
58
+ intercept: bool
59
+ moderators: tuple[ModeratorSpec, ...]
60
+ term_names: tuple[str, ...]
61
+
62
+ @property
63
+ def moderator_names(self) -> tuple[str, ...]:
64
+ """Return original moderator names in their declared order."""
65
+
66
+ return tuple(spec.name for spec in self.moderators)
67
+
68
+ def terms_for(self, moderator: str) -> tuple[str, ...]:
69
+ """Return encoded terms belonging to one original moderator."""
70
+
71
+ for spec in self.moderators:
72
+ if spec.name == moderator:
73
+ return spec.term_names
74
+ raise InvalidStudyDataError(f"Unknown moderator {moderator!r}.")
75
+
76
+
77
+ @dataclass(frozen=True, slots=True)
78
+ class NormalizedMetaRegressionData:
79
+ """Validated study rows, moderators, and their encoded design matrix."""
80
+
81
+ row_id: NDArray[np.int64]
82
+ study: NDArray[np.object_]
83
+ effect: NDArray[np.float64]
84
+ variance: NDArray[np.float64]
85
+ included: NDArray[np.bool_]
86
+ exclusion_reason: NDArray[np.object_]
87
+ moderator_values: tuple[tuple[str, NDArray[Any]], ...]
88
+ design_info: DesignInfo
89
+ design_matrix: NDArray[np.float64]
90
+ condition_number: float
91
+
92
+ @property
93
+ def included_effect(self) -> NDArray[np.float64]:
94
+ return self.effect[self.included]
95
+
96
+ @property
97
+ def included_variance(self) -> NDArray[np.float64]:
98
+ return self.variance[self.included]
99
+
100
+ @property
101
+ def included_design_matrix(self) -> NDArray[np.float64]:
102
+ return self.design_matrix[self.included]
103
+
104
+
105
+ @dataclass(frozen=True, slots=True)
106
+ class _RawModerator:
107
+ name: str
108
+ values: NDArray[Any]
109
+ missing: NDArray[np.bool_]
110
+ levels: tuple[Hashable, ...] | None
111
+
112
+
113
+ def _moderator_mapping(
114
+ moderators: ModeratorInput,
115
+ ) -> tuple[tuple[str, ColumnOrArray], ...]:
116
+ if isinstance(moderators, Mapping):
117
+ items = tuple(moderators.items())
118
+ else:
119
+ if isinstance(moderators, str | bytes):
120
+ raise InvalidStudyDataError(
121
+ "moderators must be a sequence of column names or a "
122
+ "name-to-input mapping."
123
+ )
124
+ items = tuple((name, name) for name in moderators)
125
+
126
+ if not items:
127
+ raise InvalidStudyDataError("At least one moderator must be provided.")
128
+
129
+ names: list[str] = []
130
+ for name, _ in items:
131
+ if not isinstance(name, str) or not name:
132
+ raise InvalidStudyDataError("Moderator names must be non-empty strings.")
133
+ if name in _RESERVED_TERM_NAMES:
134
+ raise InvalidStudyDataError(
135
+ f"Moderator name {name!r} is reserved for meta-regression results."
136
+ )
137
+ if name in names:
138
+ raise InvalidStudyDataError(f"Duplicate moderator name {name!r}.")
139
+ names.append(name)
140
+ return items
141
+
142
+
143
+ def _is_missing(values: NDArray[Any]) -> NDArray[np.bool_]:
144
+ missing = np.asarray(pd.isna(values), dtype=np.bool_)
145
+ if missing.ndim != 1:
146
+ raise InvalidStudyDataError("Moderator values must be scalar values.")
147
+ return missing
148
+
149
+
150
+ def _scalar_is_missing(value: Any) -> bool:
151
+ missing = pd.isna(value)
152
+ if np.ndim(missing) != 0:
153
+ raise ValueError("categorical levels and values must be scalar")
154
+ return bool(missing)
155
+
156
+
157
+ def _validate_levels(name: str, levels: Sequence[Hashable]) -> tuple[Hashable, ...]:
158
+ if isinstance(levels, str | bytes):
159
+ raise InvalidStudyDataError(
160
+ f"categorical[{name!r}] must be an ordered sequence of levels."
161
+ )
162
+ resolved = tuple(levels)
163
+ if len(resolved) < 2:
164
+ raise InvalidStudyDataError(
165
+ f"Categorical moderator {name!r} requires at least two levels."
166
+ )
167
+
168
+ seen: set[Hashable] = set()
169
+ for level in resolved:
170
+ try:
171
+ if _scalar_is_missing(level):
172
+ raise InvalidStudyDataError(
173
+ f"Categorical moderator {name!r} has a missing level."
174
+ )
175
+ hash(level)
176
+ except (TypeError, ValueError):
177
+ raise InvalidStudyDataError(
178
+ f"Levels for categorical moderator {name!r} must be non-missing "
179
+ "hashable scalar values."
180
+ ) from None
181
+ if level in seen:
182
+ raise InvalidStudyDataError(
183
+ f"Categorical moderator {name!r} has duplicate level {level!r}."
184
+ )
185
+ seen.add(level)
186
+ return resolved
187
+
188
+
189
+ def _equal(value: Any, level: Hashable) -> bool:
190
+ try:
191
+ comparison = value == level
192
+ return bool(comparison) if np.ndim(comparison) == 0 else False
193
+ except (TypeError, ValueError):
194
+ return False
195
+
196
+
197
+ def _category_codes(
198
+ values: NDArray[Any], levels: tuple[Hashable, ...]
199
+ ) -> NDArray[np.int64]:
200
+ codes = np.full(len(values), -1, dtype=np.int64)
201
+ for position, value in enumerate(values):
202
+ if _scalar_is_missing(value):
203
+ continue
204
+ for code, level in enumerate(levels):
205
+ if _equal(value, level):
206
+ codes[position] = code
207
+ break
208
+ return codes
209
+
210
+
211
+ def _numeric_values(name: str, values: NDArray[Any]) -> NDArray[np.float64]:
212
+ nonmissing = values[~_is_missing(values)]
213
+ if any(isinstance(value, str | bytes) for value in nonmissing):
214
+ raise InvalidStudyDataError(
215
+ f"Moderator {name!r} contains string values; declare it in categorical."
216
+ )
217
+ try:
218
+ numeric = np.asarray(values, dtype=np.float64)
219
+ except (TypeError, ValueError) as error:
220
+ raise InvalidStudyDataError(
221
+ f"Numeric moderator {name!r} must contain numeric values."
222
+ ) from error
223
+ missing = np.isnan(numeric)
224
+ invalid = (~missing) & (~np.isfinite(numeric))
225
+ if np.any(invalid):
226
+ rows = np.flatnonzero(invalid).tolist()
227
+ raise InvalidStudyDataError(
228
+ f"Numeric moderator {name!r} must be finite; invalid rows: {rows}."
229
+ )
230
+ return numeric
231
+
232
+
233
+ def _term_name(name: str, level: Hashable) -> str:
234
+ return f"{name}[{level}]"
235
+
236
+
237
+ def normalize_meta_regression_data(
238
+ *,
239
+ data: pd.DataFrame | None,
240
+ effect: ColumnOrArray,
241
+ variance: ColumnOrArray | None,
242
+ standard_error: ColumnOrArray | None,
243
+ moderators: ModeratorInput,
244
+ categorical: CategoricalInput | None,
245
+ study: ColumnOrArray | None,
246
+ missing: MissingPolicy,
247
+ intercept: bool,
248
+ ) -> NormalizedMetaRegressionData:
249
+ """Resolve study inputs and build a full-rank moderator design matrix."""
250
+
251
+ if not isinstance(intercept, bool):
252
+ raise InvalidStudyDataError("intercept must be a boolean.")
253
+ if missing not in {"raise", "drop"}:
254
+ raise InvalidStudyDataError("missing must be either 'raise' or 'drop'.")
255
+
256
+ moderator_items = _moderator_mapping(moderators)
257
+ categorical = {} if categorical is None else categorical
258
+ if not isinstance(categorical, Mapping):
259
+ raise InvalidStudyDataError("categorical must be a mapping or None.")
260
+ unknown_categorical = set(categorical) - {name for name, _ in moderator_items}
261
+ if unknown_categorical:
262
+ unknown_names = sorted(str(name) for name in unknown_categorical)
263
+ raise InvalidStudyDataError(
264
+ f"categorical contains unknown moderator(s): {unknown_names}."
265
+ )
266
+ if not intercept and categorical:
267
+ raise InvalidStudyDataError(
268
+ "intercept=False is only supported with numeric moderators in version 0.3."
269
+ )
270
+
271
+ # Use the existing validation path while postponing missing-value rejection
272
+ # until moderator and study fields can be reported together.
273
+ studies = normalize_studies(
274
+ data=data,
275
+ effect=effect,
276
+ variance=variance,
277
+ standard_error=standard_error,
278
+ study=study,
279
+ missing="drop",
280
+ )
281
+ row_count = len(studies.row_id)
282
+
283
+ raw_moderators: list[_RawModerator] = []
284
+ for name, value in moderator_items:
285
+ raw = _resolve_vector(value, data=data, name=f"moderator {name!r}")
286
+ if len(raw) != row_count:
287
+ raise InvalidStudyDataError(
288
+ f"Moderator {name!r} has length {len(raw)}, expected {row_count}."
289
+ )
290
+ if data is not None and len(raw) != len(data):
291
+ raise InvalidStudyDataError(
292
+ f"Moderator {name!r} must have one value per DataFrame row."
293
+ )
294
+ missing_values = _is_missing(raw)
295
+ levels = (
296
+ _validate_levels(name, categorical[name]) if name in categorical else None
297
+ )
298
+ if levels is None:
299
+ raw = _numeric_values(name, raw)
300
+ else:
301
+ codes = _category_codes(raw, levels)
302
+ unknown_levels = (~missing_values) & (codes < 0)
303
+ if np.any(unknown_levels):
304
+ rows = np.flatnonzero(unknown_levels).tolist()
305
+ raise InvalidStudyDataError(
306
+ f"Categorical moderator {name!r} contains undeclared levels "
307
+ f"at rows {rows}."
308
+ )
309
+ raw = np.asarray(raw, dtype=object)
310
+ raw_moderators.append(_RawModerator(name, raw, missing_values, levels))
311
+
312
+ study_missing = _is_missing(studies.study)
313
+ effect_missing = np.isnan(studies.effect)
314
+ uncertainty_missing = np.isnan(studies.variance)
315
+ any_missing = effect_missing | uncertainty_missing | study_missing
316
+ for moderator in raw_moderators:
317
+ any_missing |= moderator.missing
318
+
319
+ uncertainty_label = "standard error" if standard_error is not None else "variance"
320
+ reasons = np.full(row_count, None, dtype=object)
321
+ for row in np.flatnonzero(any_missing):
322
+ fields: list[str] = []
323
+ if effect_missing[row]:
324
+ fields.append("effect")
325
+ if uncertainty_missing[row]:
326
+ fields.append(uncertainty_label)
327
+ if study_missing[row]:
328
+ fields.append("study")
329
+ fields.extend(
330
+ f"moderator {moderator.name!r}"
331
+ for moderator in raw_moderators
332
+ if moderator.missing[row]
333
+ )
334
+ reasons[row] = "missing " + " and ".join(fields)
335
+
336
+ if np.any(any_missing) and missing == "raise":
337
+ details = {int(row): str(reasons[row]) for row in np.flatnonzero(any_missing)}
338
+ raise InvalidStudyDataError(
339
+ f"Missing meta-regression inputs by row: {details}; use "
340
+ "missing='drop' to exclude them explicitly."
341
+ )
342
+ included = ~any_missing
343
+ if not np.any(included):
344
+ raise InvalidStudyDataError(
345
+ "No studies remain after applying the missing-value policy."
346
+ )
347
+
348
+ specs: list[ModeratorSpec] = []
349
+ columns: list[NDArray[np.float64]] = []
350
+ term_names: list[str] = []
351
+ if intercept:
352
+ term_names.append("intercept")
353
+ columns.append(np.ones(int(np.count_nonzero(included)), dtype=np.float64))
354
+
355
+ for moderator in raw_moderators:
356
+ if moderator.levels is None:
357
+ terms: tuple[str, ...] = (moderator.name,)
358
+ columns.append(np.asarray(moderator.values[included], dtype=np.float64))
359
+ term_names.extend(terms)
360
+ specs.append(ModeratorSpec(moderator.name, "numeric", terms))
361
+ continue
362
+
363
+ codes = _category_codes(moderator.values, moderator.levels)
364
+ included_codes = codes[included]
365
+ absent = [
366
+ level
367
+ for code, level in enumerate(moderator.levels)
368
+ if not np.any(included_codes == code)
369
+ ]
370
+ if absent:
371
+ raise InvalidStudyDataError(
372
+ f"Categorical moderator {moderator.name!r} has declared levels "
373
+ f"absent after exclusions: {absent!r}."
374
+ )
375
+ terms = tuple(
376
+ _term_name(moderator.name, level) for level in moderator.levels[1:]
377
+ )
378
+ term_names.extend(terms)
379
+ columns.extend(
380
+ np.asarray(included_codes == code, dtype=np.float64)
381
+ for code in range(1, len(moderator.levels))
382
+ )
383
+ specs.append(
384
+ ModeratorSpec(
385
+ moderator.name,
386
+ "categorical",
387
+ terms,
388
+ levels=moderator.levels,
389
+ reference=moderator.levels[0],
390
+ )
391
+ )
392
+
393
+ if len(set(term_names)) != len(term_names):
394
+ raise InvalidStudyDataError(
395
+ "Moderator encoding produced duplicate term names; rename moderators "
396
+ "or categorical levels."
397
+ )
398
+
399
+ design = np.column_stack(columns).astype(np.float64, copy=False)
400
+ k, p = design.shape
401
+ if k <= p:
402
+ raise InsufficientStudiesError(
403
+ f"Meta-regression requires k > p; got {k} included studies and {p} "
404
+ "model coefficients."
405
+ )
406
+ rank = int(np.linalg.matrix_rank(design))
407
+ if rank != p:
408
+ raise InvalidStudyDataError(
409
+ f"Meta-regression design matrix is rank deficient (rank {rank}, p={p})."
410
+ )
411
+ condition_number = float(np.linalg.cond(design))
412
+
413
+ full_design = np.full((row_count, p), np.nan, dtype=np.float64)
414
+ full_design[included] = design
415
+ return NormalizedMetaRegressionData(
416
+ row_id=studies.row_id,
417
+ study=studies.study,
418
+ effect=studies.effect,
419
+ variance=studies.variance,
420
+ included=included,
421
+ exclusion_reason=reasons,
422
+ moderator_values=tuple(
423
+ (moderator.name, moderator.values.copy()) for moderator in raw_moderators
424
+ ),
425
+ design_info=DesignInfo(intercept, tuple(specs), tuple(term_names)),
426
+ design_matrix=full_design,
427
+ condition_number=condition_number,
428
+ )
429
+
430
+
431
+ def build_prediction_design_matrix(
432
+ data: pd.DataFrame,
433
+ design_info: DesignInfo,
434
+ ) -> NDArray[np.float64]:
435
+ """Encode new moderator rows using a fitted design specification."""
436
+
437
+ if not isinstance(data, pd.DataFrame):
438
+ raise InvalidStudyDataError("new_data must be a pandas DataFrame.")
439
+ if len(data) == 0:
440
+ raise InvalidStudyDataError("new_data must contain at least one row.")
441
+
442
+ columns: list[NDArray[np.float64]] = []
443
+ if design_info.intercept:
444
+ columns.append(np.ones(len(data), dtype=np.float64))
445
+
446
+ for spec in design_info.moderators:
447
+ if spec.name not in data.columns:
448
+ raise InvalidStudyDataError(
449
+ f"new_data is missing moderator column {spec.name!r}."
450
+ )
451
+ raw = data[spec.name].to_numpy(copy=True)
452
+ missing = _is_missing(raw)
453
+ if np.any(missing):
454
+ rows = np.flatnonzero(missing).tolist()
455
+ raise InvalidStudyDataError(
456
+ f"new_data moderator {spec.name!r} is missing at rows {rows}."
457
+ )
458
+ if spec.kind == "numeric":
459
+ columns.append(_numeric_values(spec.name, raw))
460
+ continue
461
+
462
+ codes = _category_codes(raw, spec.levels)
463
+ unknown = codes < 0
464
+ if np.any(unknown):
465
+ rows = np.flatnonzero(unknown).tolist()
466
+ raise InvalidStudyDataError(
467
+ f"new_data moderator {spec.name!r} has unknown levels at rows {rows}."
468
+ )
469
+ columns.extend(
470
+ np.asarray(codes == code, dtype=np.float64)
471
+ for code in range(1, len(spec.levels))
472
+ )
473
+
474
+ matrix = np.column_stack(columns).astype(np.float64, copy=False)
475
+ if matrix.shape[1] != len(design_info.term_names): # pragma: no cover
476
+ raise RuntimeError("Prediction design does not match fitted terms.")
477
+ return matrix
@@ -2,13 +2,25 @@
2
2
 
3
3
  from .inverse_variance import InverseVarianceFit, fit_inverse_variance
4
4
  from .mantel_haenszel import MantelHaenszelFit, fit_mantel_haenszel
5
+ from .meta_regression import (
6
+ MetaRegressionFit,
7
+ RegressionTestFit,
8
+ estimate_meta_regression_tau2,
9
+ fit_meta_regression,
10
+ residual_heterogeneity,
11
+ )
5
12
  from .tau2 import Tau2Estimate, estimate_tau2
6
13
 
7
14
  __all__ = [
8
15
  "InverseVarianceFit",
9
16
  "MantelHaenszelFit",
17
+ "MetaRegressionFit",
18
+ "RegressionTestFit",
10
19
  "Tau2Estimate",
11
20
  "estimate_tau2",
21
+ "estimate_meta_regression_tau2",
12
22
  "fit_inverse_variance",
13
23
  "fit_mantel_haenszel",
24
+ "fit_meta_regression",
25
+ "residual_heterogeneity",
14
26
  ]