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.
- meta_analyze/__init__.py +62 -0
- meta_analyze/_version.py +3 -0
- meta_analyze/api.py +330 -0
- meta_analyze/binary_api.py +539 -0
- meta_analyze/config.py +34 -0
- meta_analyze/continuous_api.py +353 -0
- meta_analyze/data.py +181 -0
- meta_analyze/effect_sizes/__init__.py +17 -0
- meta_analyze/effect_sizes/binary.py +412 -0
- meta_analyze/effect_sizes/continuous.py +271 -0
- meta_analyze/estimators/__init__.py +14 -0
- meta_analyze/estimators/inverse_variance.py +169 -0
- meta_analyze/estimators/mantel_haenszel.py +101 -0
- meta_analyze/estimators/tau2.py +182 -0
- meta_analyze/exceptions.py +21 -0
- meta_analyze/heterogeneity.py +99 -0
- meta_analyze/plotting/__init__.py +7 -0
- meta_analyze/plotting/_utils.py +67 -0
- meta_analyze/plotting/forest.py +193 -0
- meta_analyze/plotting/funnel.py +168 -0
- meta_analyze/plotting/subgroup_forest.py +284 -0
- meta_analyze/provenance.py +185 -0
- meta_analyze/py.typed +1 -0
- meta_analyze/reporting.py +433 -0
- meta_analyze/results.py +519 -0
- meta_analyze/sensitivity.py +546 -0
- meta_analyze/subgroups.py +165 -0
- pymetaanalysis-0.1.0.dist-info/METADATA +218 -0
- pymetaanalysis-0.1.0.dist-info/RECORD +31 -0
- pymetaanalysis-0.1.0.dist-info/WHEEL +4 -0
- pymetaanalysis-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Immutable records describing how an analysis was constructed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
from numpy.typing import NDArray
|
|
12
|
+
|
|
13
|
+
from ._version import __version__
|
|
14
|
+
from .config import MethodOptionValue
|
|
15
|
+
from .data import ColumnOrArray
|
|
16
|
+
|
|
17
|
+
PROVENANCE_SCHEMA_VERSION = "1.0"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class InputFieldProvenance:
|
|
22
|
+
"""Resolved source of one public analysis input."""
|
|
23
|
+
|
|
24
|
+
role: str
|
|
25
|
+
source: str
|
|
26
|
+
column: str | None = None
|
|
27
|
+
|
|
28
|
+
def to_dict(self) -> dict[str, str | None]:
|
|
29
|
+
"""Return a JSON-compatible representation."""
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
"role": self.role,
|
|
33
|
+
"source": self.source,
|
|
34
|
+
"column": self.column,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class TransformationRecord:
|
|
40
|
+
"""A configured data transformation and the rows it affected."""
|
|
41
|
+
|
|
42
|
+
name: str
|
|
43
|
+
parameters: tuple[tuple[str, MethodOptionValue], ...] = ()
|
|
44
|
+
affected_rows: tuple[int, ...] = ()
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict[str, object]:
|
|
47
|
+
"""Return a JSON-compatible representation."""
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
"name": self.name,
|
|
51
|
+
"parameters": dict(self.parameters),
|
|
52
|
+
"affected_rows": list(self.affected_rows),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class AnalysisProvenance:
|
|
58
|
+
"""Versioned input and transformation provenance for one fitted result."""
|
|
59
|
+
|
|
60
|
+
package_version: str
|
|
61
|
+
schema_version: str
|
|
62
|
+
analysis_type: str
|
|
63
|
+
data_source: str
|
|
64
|
+
input_fields: tuple[InputFieldProvenance, ...]
|
|
65
|
+
row_count: int
|
|
66
|
+
included_rows: tuple[int, ...]
|
|
67
|
+
excluded_rows: tuple[int, ...]
|
|
68
|
+
transformations: tuple[TransformationRecord, ...] = ()
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def column_mapping(self) -> Mapping[str, str]:
|
|
72
|
+
"""Return only inputs resolved from DataFrame columns."""
|
|
73
|
+
|
|
74
|
+
return MappingProxyType(
|
|
75
|
+
{
|
|
76
|
+
field.role: field.column
|
|
77
|
+
for field in self.input_fields
|
|
78
|
+
if field.source == "column" and field.column is not None
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def to_dict(self) -> dict[str, object]:
|
|
83
|
+
"""Return a detached, JSON-compatible provenance document."""
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
"package_version": self.package_version,
|
|
87
|
+
"schema_version": self.schema_version,
|
|
88
|
+
"analysis_type": self.analysis_type,
|
|
89
|
+
"data_source": self.data_source,
|
|
90
|
+
"input_fields": [field.to_dict() for field in self.input_fields],
|
|
91
|
+
"column_mapping": dict(self.column_mapping),
|
|
92
|
+
"row_count": self.row_count,
|
|
93
|
+
"included_rows": list(self.included_rows),
|
|
94
|
+
"excluded_rows": list(self.excluded_rows),
|
|
95
|
+
"transformations": [
|
|
96
|
+
transformation.to_dict() for transformation in self.transformations
|
|
97
|
+
],
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _input_field(
|
|
102
|
+
role: str,
|
|
103
|
+
value: ColumnOrArray | None,
|
|
104
|
+
*,
|
|
105
|
+
data: pd.DataFrame | None,
|
|
106
|
+
is_study: bool = False,
|
|
107
|
+
) -> InputFieldProvenance:
|
|
108
|
+
if isinstance(value, str):
|
|
109
|
+
return InputFieldProvenance(role=role, source="column", column=value)
|
|
110
|
+
if value is not None:
|
|
111
|
+
return InputFieldProvenance(role=role, source="array")
|
|
112
|
+
if is_study and data is not None:
|
|
113
|
+
return InputFieldProvenance(role=role, source="dataframe_index")
|
|
114
|
+
if is_study:
|
|
115
|
+
return InputFieldProvenance(role=role, source="generated_row_number")
|
|
116
|
+
raise RuntimeError(f"Input provenance for {role!r} requires a value.")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def build_analysis_provenance(
|
|
120
|
+
*,
|
|
121
|
+
analysis_type: str,
|
|
122
|
+
data: pd.DataFrame | None,
|
|
123
|
+
inputs: Sequence[tuple[str, ColumnOrArray]],
|
|
124
|
+
study: ColumnOrArray | None,
|
|
125
|
+
included: NDArray[np.bool_],
|
|
126
|
+
transformations: tuple[TransformationRecord, ...] = (),
|
|
127
|
+
) -> AnalysisProvenance:
|
|
128
|
+
"""Build a provenance record after input validation and transformations."""
|
|
129
|
+
|
|
130
|
+
fields = tuple(_input_field(role, value, data=data) for role, value in inputs) + (
|
|
131
|
+
_input_field("study", study, data=data, is_study=True),
|
|
132
|
+
)
|
|
133
|
+
included_rows = tuple(int(row) for row in np.flatnonzero(included))
|
|
134
|
+
excluded_rows = tuple(int(row) for row in np.flatnonzero(~included))
|
|
135
|
+
return AnalysisProvenance(
|
|
136
|
+
package_version=__version__,
|
|
137
|
+
schema_version=PROVENANCE_SCHEMA_VERSION,
|
|
138
|
+
analysis_type=analysis_type,
|
|
139
|
+
data_source="pandas_dataframe" if data is not None else "array_like",
|
|
140
|
+
input_fields=fields,
|
|
141
|
+
row_count=len(included),
|
|
142
|
+
included_rows=included_rows,
|
|
143
|
+
excluded_rows=excluded_rows,
|
|
144
|
+
transformations=transformations,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def add_input_field(
|
|
149
|
+
provenance: AnalysisProvenance,
|
|
150
|
+
*,
|
|
151
|
+
role: str,
|
|
152
|
+
value: ColumnOrArray,
|
|
153
|
+
data: pd.DataFrame | None,
|
|
154
|
+
) -> AnalysisProvenance:
|
|
155
|
+
"""Add a resolved input used by a composite analysis such as subgroups."""
|
|
156
|
+
|
|
157
|
+
field = _input_field(role, value, data=data)
|
|
158
|
+
return replace(provenance, input_fields=(*provenance.input_fields, field))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def remap_provenance_rows(
|
|
162
|
+
provenance: AnalysisProvenance,
|
|
163
|
+
row_ids: Sequence[int],
|
|
164
|
+
*,
|
|
165
|
+
data_source: str = "derived_subset",
|
|
166
|
+
) -> AnalysisProvenance:
|
|
167
|
+
"""Map local row positions in a derived fit back to parent row identifiers."""
|
|
168
|
+
|
|
169
|
+
mapped = tuple(int(row) for row in row_ids)
|
|
170
|
+
|
|
171
|
+
def remap(rows: tuple[int, ...]) -> tuple[int, ...]:
|
|
172
|
+
return tuple(mapped[row] for row in rows)
|
|
173
|
+
|
|
174
|
+
transformations = tuple(
|
|
175
|
+
replace(record, affected_rows=remap(record.affected_rows))
|
|
176
|
+
for record in provenance.transformations
|
|
177
|
+
)
|
|
178
|
+
return replace(
|
|
179
|
+
provenance,
|
|
180
|
+
data_source=data_source,
|
|
181
|
+
row_count=len(mapped),
|
|
182
|
+
included_rows=remap(provenance.included_rows),
|
|
183
|
+
excluded_rows=remap(provenance.excluded_rows),
|
|
184
|
+
transformations=transformations,
|
|
185
|
+
)
|
meta_analyze/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""Auditable, JSON-safe reports for fitted meta-analysis results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import date, datetime
|
|
11
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import pandas as pd
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .results import MetaAnalysisResult, SubgroupMetaAnalysisResult
|
|
18
|
+
|
|
19
|
+
REPORT_SCHEMA_VERSION = "1.1"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _json_safe(value: Any) -> Any:
|
|
23
|
+
if value is None or value is pd.NA:
|
|
24
|
+
return None
|
|
25
|
+
if isinstance(value, np.generic):
|
|
26
|
+
return _json_safe(value.item())
|
|
27
|
+
if isinstance(value, bool | int | str):
|
|
28
|
+
return value
|
|
29
|
+
if isinstance(value, float):
|
|
30
|
+
return value if math.isfinite(value) else None
|
|
31
|
+
if isinstance(value, datetime | date | pd.Timestamp):
|
|
32
|
+
return value.isoformat()
|
|
33
|
+
if isinstance(value, Mapping):
|
|
34
|
+
return {str(key): _json_safe(item) for key, item in value.items()}
|
|
35
|
+
if isinstance(value, tuple | list):
|
|
36
|
+
return [_json_safe(item) for item in value]
|
|
37
|
+
return str(value)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class ResultReport:
|
|
42
|
+
"""A detached report with structured, JSON, and Markdown representations."""
|
|
43
|
+
|
|
44
|
+
_payload: dict[str, Any] = field(repr=False)
|
|
45
|
+
_markdown: str = field(repr=False)
|
|
46
|
+
|
|
47
|
+
def __post_init__(self) -> None:
|
|
48
|
+
object.__setattr__(self, "_payload", copy.deepcopy(self._payload))
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> dict[str, Any]:
|
|
51
|
+
"""Return a defensive copy of the JSON-compatible report payload."""
|
|
52
|
+
|
|
53
|
+
return copy.deepcopy(self._payload)
|
|
54
|
+
|
|
55
|
+
def to_json(self, *, indent: int | None = 2) -> str:
|
|
56
|
+
"""Serialize the report as strict JSON; unavailable values become null."""
|
|
57
|
+
|
|
58
|
+
return json.dumps(
|
|
59
|
+
self._payload,
|
|
60
|
+
ensure_ascii=False,
|
|
61
|
+
allow_nan=False,
|
|
62
|
+
indent=indent,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def to_markdown(self) -> str:
|
|
66
|
+
"""Return a human-readable Markdown report."""
|
|
67
|
+
|
|
68
|
+
return self._markdown
|
|
69
|
+
|
|
70
|
+
def __str__(self) -> str:
|
|
71
|
+
return self.to_markdown()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _transformation_rows(result: MetaAnalysisResult, name: str) -> tuple[int, ...]:
|
|
75
|
+
for record in result.provenance.transformations:
|
|
76
|
+
if record.name == name:
|
|
77
|
+
return record.affected_rows
|
|
78
|
+
return ()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _measure_description(measure: str) -> str:
|
|
82
|
+
return {
|
|
83
|
+
"GENERIC": "generic inverse-variance effects",
|
|
84
|
+
"OR": "log odds ratios",
|
|
85
|
+
"RR": "log risk ratios",
|
|
86
|
+
"RD": "risk differences",
|
|
87
|
+
"MD": "mean differences",
|
|
88
|
+
"SMD": "Hedges' g standardized mean differences",
|
|
89
|
+
}.get(measure, measure)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def method_details(result: MetaAnalysisResult) -> str:
|
|
93
|
+
"""Generate a concise, explicit description of the fitted methods."""
|
|
94
|
+
|
|
95
|
+
method = result.method
|
|
96
|
+
model_name = "common-effect" if result.model == "common" else "random-effects"
|
|
97
|
+
pooling = (
|
|
98
|
+
"inverse-variance weighting"
|
|
99
|
+
if method.pooling_method == "inverse_variance"
|
|
100
|
+
else "the Mantel–Haenszel estimator"
|
|
101
|
+
)
|
|
102
|
+
study_word = "study" if result.k == 1 else "studies"
|
|
103
|
+
sentences = [
|
|
104
|
+
f"We performed a {model_name} meta-analysis of {result.k} {study_word} "
|
|
105
|
+
f"using {_measure_description(result.measure)}, pooled with {pooling}."
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
if result.model == "random":
|
|
109
|
+
sentences.append(
|
|
110
|
+
"Between-study variance was estimated with "
|
|
111
|
+
f"{method.tau2_method} (absolute tolerance {method.atol:g}; "
|
|
112
|
+
f"maximum {method.max_iter} iterations)."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
ci_description = {
|
|
116
|
+
"normal": "a two-sided normal approximation",
|
|
117
|
+
"hartung_knapp": "a two-sided Hartung–Knapp t interval",
|
|
118
|
+
"hartung_knapp_adhoc": (
|
|
119
|
+
"a two-sided Hartung–Knapp t interval with the ad hoc "
|
|
120
|
+
"lower-bound variance safeguard"
|
|
121
|
+
),
|
|
122
|
+
}.get(method.ci_method, method.ci_method)
|
|
123
|
+
sentences.append(
|
|
124
|
+
f"The {100.0 * method.confidence_level:g}% confidence interval used "
|
|
125
|
+
f"{ci_description}."
|
|
126
|
+
)
|
|
127
|
+
if result.i2_method == "tau2_typical_variance":
|
|
128
|
+
sentences.append(
|
|
129
|
+
"Cochran's Q used common-effect inverse-variance weights; I² and H² "
|
|
130
|
+
"used tau-squared and the typical within-study variance, where the "
|
|
131
|
+
"latter was derived from those common-effect weights."
|
|
132
|
+
)
|
|
133
|
+
else:
|
|
134
|
+
sentences.append(
|
|
135
|
+
"Cochran's Q, I², and H² used the classical Q-based definition."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if method.prediction_interval_method is not None:
|
|
139
|
+
if result.prediction_interval is not None:
|
|
140
|
+
sentences.append(
|
|
141
|
+
"A Higgins–Thompson–Spiegelhalter prediction interval was calculated."
|
|
142
|
+
)
|
|
143
|
+
else:
|
|
144
|
+
sentences.append(
|
|
145
|
+
"The configured Higgins–Thompson–Spiegelhalter prediction "
|
|
146
|
+
"interval was unavailable because fewer than three studies "
|
|
147
|
+
"were included."
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
options = dict(method.options)
|
|
151
|
+
if result.measure in {"OR", "RR", "RD"}:
|
|
152
|
+
correction = float(options.get("continuity_correction", 0.0) or 0.0)
|
|
153
|
+
scope = str(options.get("correction_scope", "none"))
|
|
154
|
+
affected = _transformation_rows(result, "continuity_correction")
|
|
155
|
+
sentences.append(
|
|
156
|
+
"Individual-study effects used continuity correction "
|
|
157
|
+
f"{correction:g} with scope {scope!r}; it affected "
|
|
158
|
+
f"{len(affected)} row(s)."
|
|
159
|
+
)
|
|
160
|
+
if result.measure == "RD":
|
|
161
|
+
rd_policy = str(options.get("rd_zero_variance", "correct"))
|
|
162
|
+
rd_affected = _transformation_rows(result, "rd_zero_variance_policy")
|
|
163
|
+
if rd_policy == "correct":
|
|
164
|
+
sentences.append(
|
|
165
|
+
"Risk-difference studies with zero uncorrected sampling "
|
|
166
|
+
"variance were retained with their raw effect; corrected "
|
|
167
|
+
"counts were used only for sampling variance. This affected "
|
|
168
|
+
f"{len(rd_affected)} row(s)."
|
|
169
|
+
)
|
|
170
|
+
else:
|
|
171
|
+
sentences.append(
|
|
172
|
+
"Risk-difference studies with zero uncorrected sampling "
|
|
173
|
+
"variance were excluded before pooling and heterogeneity "
|
|
174
|
+
f"calculations. This affected {len(rd_affected)} row(s)."
|
|
175
|
+
)
|
|
176
|
+
if method.pooling_method == "mantel_haenszel":
|
|
177
|
+
mh_correction = float(options.get("mh_continuity_correction", 0.0) or 0.0)
|
|
178
|
+
mh_scope = str(options.get("mh_correction_scope", "none"))
|
|
179
|
+
mh_affected = _transformation_rows(
|
|
180
|
+
result, "mantel_haenszel_continuity_correction"
|
|
181
|
+
)
|
|
182
|
+
sentences.append(
|
|
183
|
+
"Mantel–Haenszel pooling used continuity correction "
|
|
184
|
+
f"{mh_correction:g} with scope {mh_scope!r}; it affected "
|
|
185
|
+
f"{len(mh_affected)} row(s)."
|
|
186
|
+
)
|
|
187
|
+
elif result.measure == "SMD":
|
|
188
|
+
sentences.append(
|
|
189
|
+
"Standardized mean differences used a pooled standard deviation, "
|
|
190
|
+
"the exact Hedges small-sample correction, and the LS sampling "
|
|
191
|
+
"variance convention."
|
|
192
|
+
)
|
|
193
|
+
elif result.measure == "MD":
|
|
194
|
+
sentences.append("Mean differences used unpooled sampling variances.")
|
|
195
|
+
|
|
196
|
+
sentences.append(
|
|
197
|
+
f"Missing inputs were handled with missing={method.missing!r}. "
|
|
198
|
+
f"The analysis used PyMetaAnalysis {result.provenance.package_version}."
|
|
199
|
+
)
|
|
200
|
+
return " ".join(sentences)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def subgroup_method_details(result: SubgroupMetaAnalysisResult) -> str:
|
|
204
|
+
"""Describe the overall fit and the subgroup-comparison assumptions."""
|
|
205
|
+
|
|
206
|
+
base = method_details(result.overall)
|
|
207
|
+
strategy = (
|
|
208
|
+
"between-study variance was estimated independently within each subgroup"
|
|
209
|
+
if result.method.tau2_strategy == "independent"
|
|
210
|
+
else "between-study variance was not applicable to the common-effect fits"
|
|
211
|
+
)
|
|
212
|
+
return (
|
|
213
|
+
f"{base} Subgroup effects were fitted separately; {strategy}. "
|
|
214
|
+
"Subgroup differences were tested with a fixed-effect inverse-variance "
|
|
215
|
+
"Wald Q test on the subgroup summary estimates."
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _method_payload(result: MetaAnalysisResult) -> dict[str, Any]:
|
|
220
|
+
method = result.method
|
|
221
|
+
return {
|
|
222
|
+
"model": method.model,
|
|
223
|
+
"pooling_method": method.pooling_method,
|
|
224
|
+
"tau2_method": method.tau2_method,
|
|
225
|
+
"ci_method": method.ci_method,
|
|
226
|
+
"confidence_level": method.confidence_level,
|
|
227
|
+
"prediction_interval_method": method.prediction_interval_method,
|
|
228
|
+
"missing": method.missing,
|
|
229
|
+
"atol": method.atol,
|
|
230
|
+
"max_iter": method.max_iter,
|
|
231
|
+
"options": dict(method.options),
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _meta_payload(
|
|
236
|
+
result: MetaAnalysisResult,
|
|
237
|
+
*,
|
|
238
|
+
include_studies: bool,
|
|
239
|
+
) -> dict[str, Any]:
|
|
240
|
+
payload: dict[str, Any] = {
|
|
241
|
+
"analysis": {
|
|
242
|
+
"model": result.model,
|
|
243
|
+
"measure": result.measure,
|
|
244
|
+
"effect_scale": result.effect_scale,
|
|
245
|
+
"display_scale": result.display_scale,
|
|
246
|
+
"included_studies": result.k,
|
|
247
|
+
"total_rows": result.provenance.row_count,
|
|
248
|
+
},
|
|
249
|
+
"results": {
|
|
250
|
+
"estimate": result.estimate,
|
|
251
|
+
"standard_error": result.standard_error,
|
|
252
|
+
"ci": list(result.ci),
|
|
253
|
+
"prediction_interval": result.prediction_interval,
|
|
254
|
+
"display_estimate": result.display_estimate,
|
|
255
|
+
"display_ci": list(result.display_ci),
|
|
256
|
+
"display_prediction_interval": result.display_prediction_interval,
|
|
257
|
+
"tau2": result.tau2,
|
|
258
|
+
},
|
|
259
|
+
"heterogeneity": {
|
|
260
|
+
"q": result.q,
|
|
261
|
+
"df": result.q_df,
|
|
262
|
+
"pvalue": result.q_pvalue,
|
|
263
|
+
"i2": result.i2,
|
|
264
|
+
"h2": result.h2,
|
|
265
|
+
"i2_method": result.i2_method,
|
|
266
|
+
},
|
|
267
|
+
"method": _method_payload(result),
|
|
268
|
+
"diagnostics": {
|
|
269
|
+
"converged": result.diagnostics.converged,
|
|
270
|
+
"iterations": result.diagnostics.iterations,
|
|
271
|
+
"tau2_at_boundary": result.diagnostics.tau2_at_boundary,
|
|
272
|
+
},
|
|
273
|
+
"provenance": result.provenance.to_dict(),
|
|
274
|
+
"warnings": list(result.warnings),
|
|
275
|
+
}
|
|
276
|
+
if include_studies:
|
|
277
|
+
payload["studies"] = result.study_results.to_dict(orient="records")
|
|
278
|
+
return cast(dict[str, Any], _json_safe(payload))
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _number(value: Any) -> str:
|
|
282
|
+
if value is None:
|
|
283
|
+
return "not available"
|
|
284
|
+
numeric = float(value)
|
|
285
|
+
return f"{numeric:.6g}" if math.isfinite(numeric) else "not available"
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _meta_markdown(result: MetaAnalysisResult) -> str:
|
|
289
|
+
display_ci = result.display_ci
|
|
290
|
+
lines = [
|
|
291
|
+
"# Meta-analysis report",
|
|
292
|
+
"",
|
|
293
|
+
"## Results",
|
|
294
|
+
"",
|
|
295
|
+
f"- Model: {result.model}-effect {result.measure}",
|
|
296
|
+
(f"- Studies: {result.k} included of {result.provenance.row_count} input rows"),
|
|
297
|
+
(
|
|
298
|
+
f"- Estimate: {_number(result.display_estimate)} "
|
|
299
|
+
f"({100.0 * result.method.confidence_level:g}% CI "
|
|
300
|
+
f"{_number(display_ci[0])} to {_number(display_ci[1])})"
|
|
301
|
+
),
|
|
302
|
+
f"- Heterogeneity: Q({result.q_df})={_number(result.q)}, "
|
|
303
|
+
f"p={_number(result.q_pvalue)}; I²={_number(100.0 * result.i2)}%; "
|
|
304
|
+
f"H²={_number(result.h2)} ({result.i2_method})",
|
|
305
|
+
]
|
|
306
|
+
if result.model == "random":
|
|
307
|
+
lines.append(f"- τ²: {_number(result.tau2)} ({result.method.tau2_method})")
|
|
308
|
+
if result.display_scale != result.effect_scale:
|
|
309
|
+
lines.append(
|
|
310
|
+
f"- Model-scale estimate: {_number(result.estimate)} "
|
|
311
|
+
f"({result.effect_scale} scale)"
|
|
312
|
+
)
|
|
313
|
+
if result.display_prediction_interval is not None:
|
|
314
|
+
low, high = result.display_prediction_interval
|
|
315
|
+
lines.append(f"- Prediction interval: {_number(low)} to {_number(high)}")
|
|
316
|
+
|
|
317
|
+
lines.extend(
|
|
318
|
+
[
|
|
319
|
+
"",
|
|
320
|
+
"## Methods",
|
|
321
|
+
"",
|
|
322
|
+
method_details(result),
|
|
323
|
+
"",
|
|
324
|
+
"## Provenance and diagnostics",
|
|
325
|
+
"",
|
|
326
|
+
f"- Package version: {result.provenance.package_version}",
|
|
327
|
+
f"- Provenance schema: {result.provenance.schema_version}",
|
|
328
|
+
f"- Input source: {result.provenance.data_source}",
|
|
329
|
+
f"- Converged: {result.diagnostics.converged}",
|
|
330
|
+
f"- Iterations: {result.diagnostics.iterations}",
|
|
331
|
+
(f"- Excluded row IDs: {list(result.provenance.excluded_rows) or 'none'}"),
|
|
332
|
+
]
|
|
333
|
+
)
|
|
334
|
+
if result.warnings:
|
|
335
|
+
lines.extend(["", "## Notes", ""])
|
|
336
|
+
lines.extend(f"- {warning}" for warning in result.warnings)
|
|
337
|
+
return "\n".join(lines)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def build_report(
|
|
341
|
+
result: MetaAnalysisResult,
|
|
342
|
+
*,
|
|
343
|
+
include_studies: bool = True,
|
|
344
|
+
) -> ResultReport:
|
|
345
|
+
"""Build a detached report for a single meta-analysis result."""
|
|
346
|
+
|
|
347
|
+
payload = {
|
|
348
|
+
"schema_version": REPORT_SCHEMA_VERSION,
|
|
349
|
+
"report_type": "meta_analysis",
|
|
350
|
+
**_meta_payload(result, include_studies=include_studies),
|
|
351
|
+
"method_details": method_details(result),
|
|
352
|
+
}
|
|
353
|
+
return ResultReport(_json_safe(payload), _meta_markdown(result))
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _subgroup_markdown(result: SubgroupMetaAnalysisResult) -> str:
|
|
357
|
+
lines = [
|
|
358
|
+
"# Subgroup meta-analysis report",
|
|
359
|
+
"",
|
|
360
|
+
"## Results",
|
|
361
|
+
"",
|
|
362
|
+
"| Subgroup | Studies | Estimate | Confidence interval |",
|
|
363
|
+
"| --- | ---: | ---: | ---: |",
|
|
364
|
+
]
|
|
365
|
+
for label, group in result.groups.items():
|
|
366
|
+
low, high = group.display_ci
|
|
367
|
+
safe_label = str(label).replace("|", "\\|")
|
|
368
|
+
lines.append(
|
|
369
|
+
f"| {safe_label} | {group.k} | {_number(group.display_estimate)} | "
|
|
370
|
+
f"{_number(low)} to {_number(high)} |"
|
|
371
|
+
)
|
|
372
|
+
lines.extend(
|
|
373
|
+
[
|
|
374
|
+
"",
|
|
375
|
+
(
|
|
376
|
+
"Test for subgroup differences: "
|
|
377
|
+
f"Q({result.q_between_df})={_number(result.q_between)}, "
|
|
378
|
+
f"p={_number(result.q_between_pvalue)}, "
|
|
379
|
+
f"I²={_number(100.0 * result.i2_between)}%."
|
|
380
|
+
),
|
|
381
|
+
"",
|
|
382
|
+
"## Methods",
|
|
383
|
+
"",
|
|
384
|
+
subgroup_method_details(result),
|
|
385
|
+
"",
|
|
386
|
+
"## Provenance and diagnostics",
|
|
387
|
+
"",
|
|
388
|
+
f"- Package version: {result.overall.provenance.package_version}",
|
|
389
|
+
f"- Input source: {result.overall.provenance.data_source}",
|
|
390
|
+
f"- τ² strategy: {result.method.tau2_strategy}",
|
|
391
|
+
f"- Subgroup test: {result.method.test_method}",
|
|
392
|
+
]
|
|
393
|
+
)
|
|
394
|
+
warnings = (*result.overall.warnings, *result.warnings)
|
|
395
|
+
if warnings:
|
|
396
|
+
lines.extend(["", "## Notes", ""])
|
|
397
|
+
lines.extend(f"- {warning}" for warning in warnings)
|
|
398
|
+
return "\n".join(lines)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def build_subgroup_report(
|
|
402
|
+
result: SubgroupMetaAnalysisResult,
|
|
403
|
+
*,
|
|
404
|
+
include_studies: bool = True,
|
|
405
|
+
) -> ResultReport:
|
|
406
|
+
"""Build a detached report for a subgroup meta-analysis result."""
|
|
407
|
+
|
|
408
|
+
payload: dict[str, Any] = {
|
|
409
|
+
"schema_version": REPORT_SCHEMA_VERSION,
|
|
410
|
+
"report_type": "subgroup_meta_analysis",
|
|
411
|
+
"overall": _meta_payload(result.overall, include_studies=False),
|
|
412
|
+
"groups": [
|
|
413
|
+
{
|
|
414
|
+
"label": _json_safe(label),
|
|
415
|
+
"result": _meta_payload(group, include_studies=False),
|
|
416
|
+
}
|
|
417
|
+
for label, group in result.groups.items()
|
|
418
|
+
],
|
|
419
|
+
"subgroup_differences": {
|
|
420
|
+
"q": result.q_between,
|
|
421
|
+
"df": result.q_between_df,
|
|
422
|
+
"pvalue": result.q_between_pvalue,
|
|
423
|
+
"i2": result.i2_between,
|
|
424
|
+
"tau2_strategy": result.method.tau2_strategy,
|
|
425
|
+
"test_method": result.method.test_method,
|
|
426
|
+
"subgroup_missing": result.method.subgroup_missing,
|
|
427
|
+
},
|
|
428
|
+
"method_details": subgroup_method_details(result),
|
|
429
|
+
"warnings": list(result.warnings),
|
|
430
|
+
}
|
|
431
|
+
if include_studies:
|
|
432
|
+
payload["studies"] = result.study_results.to_dict(orient="records")
|
|
433
|
+
return ResultReport(_json_safe(payload), _subgroup_markdown(result))
|