PyMetaAnalysis 0.2.1__py3-none-any.whl → 0.3.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 +17 -1
- meta_analyze/_version.py +1 -1
- meta_analyze/config.py +18 -0
- meta_analyze/design_matrix.py +477 -0
- meta_analyze/estimators/__init__.py +12 -0
- meta_analyze/estimators/meta_regression.py +462 -0
- meta_analyze/plotting/__init__.py +2 -1
- meta_analyze/plotting/_utils.py +2 -2
- meta_analyze/plotting/regression.py +128 -0
- meta_analyze/regression_api.py +396 -0
- meta_analyze/regression_results.py +388 -0
- meta_analyze/reporting.py +169 -1
- {pymetaanalysis-0.2.1.dist-info → pymetaanalysis-0.3.0.dist-info}/METADATA +22 -1
- {pymetaanalysis-0.2.1.dist-info → pymetaanalysis-0.3.0.dist-info}/RECORD +16 -11
- {pymetaanalysis-0.2.1.dist-info → pymetaanalysis-0.3.0.dist-info}/WHEEL +0 -0
- {pymetaanalysis-0.2.1.dist-info → pymetaanalysis-0.3.0.dist-info}/licenses/LICENSE +0 -0
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,13 @@ from .provenance import (
|
|
|
17
18
|
InputFieldProvenance,
|
|
18
19
|
TransformationRecord,
|
|
19
20
|
)
|
|
21
|
+
from .regression_api import meta_regression
|
|
22
|
+
from .regression_results import (
|
|
23
|
+
MetaRegressionDiagnostics,
|
|
24
|
+
MetaRegressionResult,
|
|
25
|
+
MetaRegressionSummary,
|
|
26
|
+
ModeratorTestResult,
|
|
27
|
+
)
|
|
20
28
|
from .reporting import ResultReport
|
|
21
29
|
from .results import (
|
|
22
30
|
FitDiagnostics,
|
|
@@ -37,6 +45,7 @@ __all__ = [
|
|
|
37
45
|
"AnalysisProvenance",
|
|
38
46
|
"ConvergenceError",
|
|
39
47
|
"CumulativeMetaAnalysisResult",
|
|
48
|
+
"DesignInfo",
|
|
40
49
|
"FitDiagnostics",
|
|
41
50
|
"HeterogeneityResult",
|
|
42
51
|
"InsufficientStudiesError",
|
|
@@ -46,7 +55,13 @@ __all__ = [
|
|
|
46
55
|
"MetaAnalysisError",
|
|
47
56
|
"MetaAnalysisResult",
|
|
48
57
|
"MetaAnalysisSummary",
|
|
58
|
+
"MetaRegressionDiagnostics",
|
|
59
|
+
"MetaRegressionMethodConfig",
|
|
60
|
+
"MetaRegressionResult",
|
|
61
|
+
"MetaRegressionSummary",
|
|
49
62
|
"MethodConfig",
|
|
63
|
+
"ModeratorSpec",
|
|
64
|
+
"ModeratorTestResult",
|
|
50
65
|
"ResultReport",
|
|
51
66
|
"SubgroupMetaAnalysisResult",
|
|
52
67
|
"SubgroupMetaAnalysisSummary",
|
|
@@ -58,5 +73,6 @@ __all__ = [
|
|
|
58
73
|
"meta_analysis",
|
|
59
74
|
"meta_binary",
|
|
60
75
|
"meta_continuous",
|
|
76
|
+
"meta_regression",
|
|
61
77
|
"__version__",
|
|
62
78
|
]
|
meta_analyze/_version.py
CHANGED
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
|
]
|