PyMetaAnalysis 0.3.0__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
@@ -19,12 +19,21 @@ from .provenance import (
19
19
  TransformationRecord,
20
20
  )
21
21
  from .regression_api import meta_regression
22
+ from .regression_collinearity import MetaRegressionCollinearityResult
23
+ from .regression_contrasts import (
24
+ LinearContrastTestResult,
25
+ MetaRegressionContrastResult,
26
+ )
22
27
  from .regression_results import (
23
28
  MetaRegressionDiagnostics,
24
29
  MetaRegressionResult,
25
30
  MetaRegressionSummary,
26
31
  ModeratorTestResult,
27
32
  )
33
+ from .regression_sensitivity import (
34
+ MetaRegressionInfluenceResult,
35
+ MetaRegressionLeaveOneOutResult,
36
+ )
28
37
  from .reporting import ResultReport
29
38
  from .results import (
30
39
  FitDiagnostics,
@@ -52,10 +61,15 @@ __all__ = [
52
61
  "InputFieldProvenance",
53
62
  "InvalidStudyDataError",
54
63
  "LeaveOneOutResult",
64
+ "LinearContrastTestResult",
55
65
  "MetaAnalysisError",
56
66
  "MetaAnalysisResult",
57
67
  "MetaAnalysisSummary",
68
+ "MetaRegressionCollinearityResult",
69
+ "MetaRegressionContrastResult",
58
70
  "MetaRegressionDiagnostics",
71
+ "MetaRegressionInfluenceResult",
72
+ "MetaRegressionLeaveOneOutResult",
59
73
  "MetaRegressionMethodConfig",
60
74
  "MetaRegressionResult",
61
75
  "MetaRegressionSummary",
meta_analyze/_version.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Single source of truth for the package version."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
@@ -21,7 +21,7 @@ from .estimators import (
21
21
  fit_meta_regression,
22
22
  residual_heterogeneity,
23
23
  )
24
- from .exceptions import UnsupportedMethodError
24
+ from .exceptions import InsufficientStudiesError, UnsupportedMethodError
25
25
  from .provenance import TransformationRecord, build_analysis_provenance
26
26
  from .regression_results import (
27
27
  MetaRegressionDiagnostics,
@@ -62,6 +62,22 @@ def _normalize_inference_method(inference_method: str) -> str:
62
62
  return aliases.get(normalized, normalized)
63
63
 
64
64
 
65
+ def _normalize_prediction_interval_method(method: str) -> str:
66
+ normalized = method.lower().replace("-", "_")
67
+ aliases = {
68
+ "default": "normal_or_t_k_minus_p",
69
+ "normal_or_t_k_minus_p": "normal_or_t_k_minus_p",
70
+ "riley": "riley",
71
+ }
72
+ try:
73
+ return aliases[normalized]
74
+ except KeyError as error:
75
+ raise UnsupportedMethodError(
76
+ "Unsupported prediction_interval_method="
77
+ f"{method!r}; expected 'default' or 'riley'."
78
+ ) from error
79
+
80
+
65
81
  def _moderator_inputs(
66
82
  moderators: ModeratorInput,
67
83
  ) -> tuple[tuple[str, ColumnOrArray], ...]:
@@ -86,6 +102,7 @@ def meta_regression(
86
102
  inference_method: str = "normal",
87
103
  intercept: bool = True,
88
104
  confidence_level: float = 0.95,
105
+ prediction_interval_method: str = "default",
89
106
  missing: MissingPolicy = "raise",
90
107
  atol: float = 1e-10,
91
108
  max_iter: int = 1000,
@@ -97,7 +114,9 @@ def meta_regression(
97
114
  moderators must be declared explicitly as ordered level sequences; the
98
115
  first level is the treatment-coding reference. Coefficients describe
99
116
  study-level associations and do not establish individual-level or causal
100
- effects.
117
+ effects. Mixed-effects true-effect prediction intervals use the fitted
118
+ inference distribution by default; ``prediction_interval_method="riley"``
119
+ selects a t critical value with ``k-p-1`` degrees of freedom.
101
120
  """
102
121
 
103
122
  confidence_level, atol, max_iter = _validate_analysis_controls(
@@ -107,7 +126,14 @@ def meta_regression(
107
126
  )
108
127
  normalized_model = _normalize_regression_model(model)
109
128
  normalized_inference = _normalize_inference_method(inference_method)
129
+ normalized_prediction_interval = _normalize_prediction_interval_method(
130
+ prediction_interval_method
131
+ )
110
132
  normalized_tau2 = tau2_method.upper().replace("-", "_")
133
+ if normalized_model == "common" and normalized_prediction_interval == "riley":
134
+ raise UnsupportedMethodError(
135
+ "prediction_interval_method='riley' requires a mixed-effects model."
136
+ )
111
137
 
112
138
  normalized = normalize_meta_regression_data(
113
139
  data=data,
@@ -123,6 +149,14 @@ def meta_regression(
123
149
  included_effect = normalized.included_effect
124
150
  included_variance = normalized.included_variance
125
151
  design = normalized.included_design_matrix
152
+ if (
153
+ normalized_prediction_interval == "riley"
154
+ and len(included_effect) - design.shape[1] <= 1
155
+ ):
156
+ raise InsufficientStudiesError(
157
+ "prediction_interval_method='riley' requires at least two residual "
158
+ "degrees of freedom (k-p >= 2)."
159
+ )
126
160
  fit = fit_meta_regression(
127
161
  included_effect,
128
162
  included_variance,
@@ -342,7 +376,7 @@ def meta_regression(
342
376
  if spec.kind == "categorical"
343
377
  ),
344
378
  prediction_interval_method=(
345
- "normal_or_t_k_minus_p" if normalized_model == "mixed" else None
379
+ normalized_prediction_interval if normalized_model == "mixed" else None
346
380
  ),
347
381
  missing=missing,
348
382
  atol=atol,
@@ -0,0 +1,304 @@
1
+ """Collinearity diagnostics for fitted meta-regression models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ from numpy.typing import NDArray
11
+
12
+ from .exceptions import InvalidStudyDataError
13
+
14
+ if TYPE_CHECKING:
15
+ from .regression_results import MetaRegressionResult
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class MetaRegressionCollinearityResult:
20
+ """VIF/GVIF and weighted condition diagnostics for a fitted design."""
21
+
22
+ original: MetaRegressionResult
23
+ raw_condition_number: float
24
+ weighted_scaled_condition_number: float
25
+ condition_index_reference: float
26
+ variance_proportion_reference: float
27
+ warnings: tuple[str, ...]
28
+ _term_vif: pd.DataFrame = field(repr=False, compare=False)
29
+ _moderator_gvif: pd.DataFrame = field(repr=False, compare=False)
30
+ _condition_indices: pd.DataFrame = field(repr=False, compare=False)
31
+ _variance_proportions: pd.DataFrame = field(repr=False, compare=False)
32
+
33
+ def __post_init__(self) -> None:
34
+ object.__setattr__(self, "_term_vif", self._term_vif.copy(deep=True))
35
+ object.__setattr__(
36
+ self, "_moderator_gvif", self._moderator_gvif.copy(deep=True)
37
+ )
38
+ object.__setattr__(
39
+ self, "_condition_indices", self._condition_indices.copy(deep=True)
40
+ )
41
+ object.__setattr__(
42
+ self,
43
+ "_variance_proportions",
44
+ self._variance_proportions.copy(deep=True),
45
+ )
46
+
47
+ @property
48
+ def term_vif(self) -> pd.DataFrame:
49
+ """Return VIF and standard-error inflation for encoded terms."""
50
+
51
+ return self._term_vif.copy(deep=True)
52
+
53
+ @property
54
+ def moderator_gvif(self) -> pd.DataFrame:
55
+ """Return moderator-level GVIF and dimension-adjusted GSIF."""
56
+
57
+ return self._moderator_gvif.copy(deep=True)
58
+
59
+ @property
60
+ def condition_indices(self) -> pd.DataFrame:
61
+ """Return singular-value dimensions and their condition indices."""
62
+
63
+ return self._condition_indices.copy(deep=True)
64
+
65
+ @property
66
+ def variance_proportions(self) -> pd.DataFrame:
67
+ """Return long-form coefficient variance-decomposition proportions."""
68
+
69
+ return self._variance_proportions.copy(deep=True)
70
+
71
+ @property
72
+ def concerning_dimensions(self) -> pd.DataFrame:
73
+ """Return dimensions meeting both documented collinearity references."""
74
+
75
+ return (
76
+ self._condition_indices.loc[lambda frame: frame["concerning"]]
77
+ .reset_index(drop=True)
78
+ .copy(deep=True)
79
+ )
80
+
81
+
82
+ def _classic_covariance(result: MetaRegressionResult) -> NDArray[np.float64]:
83
+ studies = result.study_results
84
+ included = studies["included"].to_numpy(dtype=np.bool_, copy=True)
85
+ variance = studies.loc[included, "variance"].to_numpy(dtype=np.float64, copy=True)
86
+ design = result.design_matrix.to_numpy(dtype=np.float64, copy=True)
87
+ denominator = variance + result.tau2
88
+ variance_scale = float(np.min(denominator))
89
+ relative_weights = variance_scale / denominator
90
+ gram = design.T @ (relative_weights[:, np.newaxis] * design)
91
+ try:
92
+ inverse = np.linalg.solve(gram, np.eye(gram.shape[0]))
93
+ except np.linalg.LinAlgError as error: # pragma: no cover - fit is full rank
94
+ raise InvalidStudyDataError(
95
+ "Meta-regression coefficient covariance could not be reconstructed."
96
+ ) from error
97
+ inverse = 0.5 * (inverse + inverse.T)
98
+ return variance_scale * inverse
99
+
100
+
101
+ def _covariance_correlation(
102
+ covariance: NDArray[np.float64],
103
+ ) -> NDArray[np.float64]:
104
+ diagonal = np.diag(covariance)
105
+ if np.any(diagonal <= 0.0):
106
+ raise InvalidStudyDataError(
107
+ "VIF diagnostics require positive coefficient variances."
108
+ )
109
+ standard_errors = np.sqrt(diagonal)
110
+ correlation = covariance / np.outer(standard_errors, standard_errors)
111
+ correlation = 0.5 * (correlation + correlation.T)
112
+ np.fill_diagonal(correlation, 1.0)
113
+ return np.asarray(correlation, dtype=np.float64)
114
+
115
+
116
+ def _log_determinant(
117
+ matrix: NDArray[np.float64], positions: NDArray[np.int64]
118
+ ) -> float:
119
+ if positions.size == 0:
120
+ return 0.0
121
+ selected = matrix[np.ix_(positions, positions)]
122
+ sign, value = np.linalg.slogdet(selected)
123
+ if sign <= 0.0:
124
+ raise InvalidStudyDataError(
125
+ "VIF diagnostics require a positive-definite coefficient "
126
+ "correlation matrix."
127
+ )
128
+ return float(value)
129
+
130
+
131
+ def _gvif(correlation: NDArray[np.float64], positions: NDArray[np.int64]) -> float:
132
+ all_positions = np.arange(correlation.shape[0], dtype=np.int64)
133
+ complement = all_positions[~np.isin(all_positions, positions)]
134
+ log_gvif = (
135
+ _log_determinant(correlation, positions)
136
+ + _log_determinant(correlation, complement)
137
+ - _log_determinant(correlation, all_positions)
138
+ )
139
+ if log_gvif >= np.log(np.finfo(np.float64).max):
140
+ return float("inf")
141
+ return max(1.0, float(np.exp(log_gvif)))
142
+
143
+
144
+ def _vif_tables(
145
+ result: MetaRegressionResult,
146
+ covariance: NDArray[np.float64],
147
+ ) -> tuple[pd.DataFrame, pd.DataFrame]:
148
+ offset = 1 if result.design_info.intercept else 0
149
+ terms = result.design_info.term_names[offset:]
150
+ correlation = _covariance_correlation(covariance[offset:, offset:])
151
+ moderator_for_term = {
152
+ term: spec.name
153
+ for spec in result.design_info.moderators
154
+ for term in spec.term_names
155
+ }
156
+
157
+ term_rows: list[dict[str, object]] = []
158
+ for position, term in enumerate(terms):
159
+ vif = _gvif(correlation, np.asarray([position], dtype=np.int64))
160
+ term_rows.append(
161
+ {
162
+ "term": term,
163
+ "moderator": moderator_for_term[term],
164
+ "vif": vif,
165
+ "sif": float(np.sqrt(vif)),
166
+ }
167
+ )
168
+
169
+ moderator_rows: list[dict[str, object]] = []
170
+ for spec in result.design_info.moderators:
171
+ positions = np.asarray(
172
+ [terms.index(term) for term in spec.term_names], dtype=np.int64
173
+ )
174
+ gvif = _gvif(correlation, positions)
175
+ term_count = len(positions)
176
+ moderator_rows.append(
177
+ {
178
+ "moderator": spec.name,
179
+ "kind": spec.kind,
180
+ "terms": spec.term_names,
181
+ "term_count": term_count,
182
+ "gvif": gvif,
183
+ "gsif": float(gvif ** (1.0 / (2.0 * term_count))),
184
+ }
185
+ )
186
+ return pd.DataFrame(term_rows), pd.DataFrame(moderator_rows)
187
+
188
+
189
+ def _condition_tables(
190
+ result: MetaRegressionResult,
191
+ *,
192
+ condition_reference: float,
193
+ variance_reference: float,
194
+ ) -> tuple[pd.DataFrame, pd.DataFrame, float]:
195
+ studies = result.study_results
196
+ included = studies["included"].to_numpy(dtype=np.bool_, copy=True)
197
+ variance = studies.loc[included, "variance"].to_numpy(dtype=np.float64, copy=True)
198
+ design = result.design_matrix.to_numpy(dtype=np.float64, copy=True)
199
+ weights = 1.0 / (variance + result.tau2)
200
+ weighted_design = np.sqrt(weights)[:, np.newaxis] * design
201
+ column_norms = np.linalg.norm(weighted_design, axis=0)
202
+ if np.any(column_norms == 0.0): # pragma: no cover - full rank checked at fit
203
+ raise InvalidStudyDataError(
204
+ "Condition diagnostics require nonzero weighted design columns."
205
+ )
206
+ scaled_design = weighted_design / column_norms
207
+ _, singular_values, right_vectors_transposed = np.linalg.svd(
208
+ scaled_design, full_matrices=False
209
+ )
210
+ eigenvalues = singular_values * singular_values
211
+ condition_indices = singular_values[0] / singular_values
212
+ variance_components = (
213
+ right_vectors_transposed.T * right_vectors_transposed.T
214
+ ) / eigenvalues[np.newaxis, :]
215
+ variance_proportions = variance_components / np.sum(
216
+ variance_components, axis=1, keepdims=True
217
+ )
218
+
219
+ condition_rows: list[dict[str, object]] = []
220
+ variance_rows: list[dict[str, object]] = []
221
+ term_to_moderator = {
222
+ term: spec.name
223
+ for spec in result.design_info.moderators
224
+ for term in spec.term_names
225
+ }
226
+ for position, (singular_value, eigenvalue, condition_index) in enumerate(
227
+ zip(singular_values, eigenvalues, condition_indices, strict=True), start=1
228
+ ):
229
+ high_variance_count = int(
230
+ np.count_nonzero(variance_proportions[:, position - 1] > variance_reference)
231
+ )
232
+ high_condition = bool(condition_index > condition_reference)
233
+ concerning = bool(high_condition and high_variance_count >= 2)
234
+ condition_rows.append(
235
+ {
236
+ "dimension": position,
237
+ "singular_value": singular_value,
238
+ "eigenvalue": eigenvalue,
239
+ "condition_index": condition_index,
240
+ "high_condition_index": high_condition,
241
+ "high_variance_term_count": high_variance_count,
242
+ "concerning": concerning,
243
+ }
244
+ )
245
+ for term, proportion in zip(
246
+ result.design_info.term_names,
247
+ variance_proportions[:, position - 1],
248
+ strict=True,
249
+ ):
250
+ variance_rows.append(
251
+ {
252
+ "dimension": position,
253
+ "condition_index": condition_index,
254
+ "term": term,
255
+ "moderator": term_to_moderator.get(term),
256
+ "variance_proportion": proportion,
257
+ "high_variance_proportion": bool(proportion > variance_reference),
258
+ }
259
+ )
260
+ return (
261
+ pd.DataFrame(condition_rows),
262
+ pd.DataFrame(variance_rows),
263
+ float(condition_indices[-1]),
264
+ )
265
+
266
+
267
+ def meta_regression_collinearity(
268
+ result: MetaRegressionResult,
269
+ ) -> MetaRegressionCollinearityResult:
270
+ """Compute coefficient inflation and weighted design diagnostics."""
271
+
272
+ condition_reference = 30.0
273
+ variance_reference = 0.5
274
+ covariance = _classic_covariance(result)
275
+ term_vif, moderator_gvif = _vif_tables(result, covariance)
276
+ condition_indices, variance_proportions, condition_number = _condition_tables(
277
+ result,
278
+ condition_reference=condition_reference,
279
+ variance_reference=variance_reference,
280
+ )
281
+ warnings: list[str] = []
282
+ if condition_indices["high_condition_index"].any():
283
+ warnings.append(
284
+ "At least one weighted, column-scaled condition index exceeds the "
285
+ "heuristic reference of 30; inspect variance-decomposition "
286
+ "proportions."
287
+ )
288
+ if condition_indices["concerning"].any():
289
+ warnings.append(
290
+ "At least one high-condition dimension concentrates more than 50% "
291
+ "of the coefficient variance for multiple terms."
292
+ )
293
+ return MetaRegressionCollinearityResult(
294
+ original=result,
295
+ raw_condition_number=result.diagnostics.condition_number,
296
+ weighted_scaled_condition_number=condition_number,
297
+ condition_index_reference=condition_reference,
298
+ variance_proportion_reference=variance_reference,
299
+ warnings=tuple(warnings),
300
+ _term_vif=term_vif,
301
+ _moderator_gvif=moderator_gvif,
302
+ _condition_indices=condition_indices,
303
+ _variance_proportions=variance_proportions,
304
+ )