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,412 @@
1
+ """Effect sizes and validation for two-group binary outcomes.
2
+
3
+ OR, RR, and RD equations follow the publicly documented Review Manager 5
4
+ statistical algorithms by Deeks and Higgins (2010).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from numbers import Real
11
+ from typing import Literal, TypeAlias, cast
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+ from numpy.typing import NDArray
16
+
17
+ from ..data import (
18
+ ColumnOrArray,
19
+ MissingPolicy,
20
+ _resolve_vector,
21
+ _study_labels,
22
+ )
23
+ from ..exceptions import InvalidStudyDataError, UnsupportedMethodError
24
+
25
+ CorrectionScope: TypeAlias = Literal[
26
+ "only_zero_studies", "all_studies", "if_any_zero", "none"
27
+ ]
28
+ RDZeroVariancePolicy: TypeAlias = Literal["correct", "exclude"]
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class BinaryStudies:
33
+ """Validated raw counts and row-level inclusion metadata."""
34
+
35
+ row_id: NDArray[np.int64]
36
+ study: NDArray[np.object_]
37
+ event_treat: NDArray[np.float64]
38
+ n_treat: NDArray[np.float64]
39
+ event_control: NDArray[np.float64]
40
+ n_control: NDArray[np.float64]
41
+ included: NDArray[np.bool_]
42
+ exclusion_reason: NDArray[np.object_]
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class BinaryEffectData:
47
+ """Binary study effects on the model scale."""
48
+
49
+ studies: BinaryStudies
50
+ effect: NDArray[np.float64]
51
+ variance: NDArray[np.float64]
52
+ corrected: NDArray[np.bool_]
53
+ rd_zero_variance: NDArray[np.bool_]
54
+ measure: str
55
+ effect_scale: str
56
+ display_scale: str
57
+
58
+ @property
59
+ def included_effect(self) -> NDArray[np.float64]:
60
+ return self.effect[self.studies.included]
61
+
62
+ @property
63
+ def included_variance(self) -> NDArray[np.float64]:
64
+ return self.variance[self.studies.included]
65
+
66
+
67
+ def normalize_correction_scope(scope: str) -> CorrectionScope:
68
+ normalized = scope.lower().replace("-", "_")
69
+ aliases = {
70
+ "only0": "only_zero_studies",
71
+ "only_zero_cells": "only_zero_studies",
72
+ "all": "all_studies",
73
+ "if0all": "if_any_zero",
74
+ }
75
+ normalized = aliases.get(normalized, normalized)
76
+ allowed = {"only_zero_studies", "all_studies", "if_any_zero", "none"}
77
+ if normalized not in allowed:
78
+ raise UnsupportedMethodError(
79
+ "correction_scope must be 'only_zero_studies', 'all_studies', "
80
+ "'if_any_zero', or 'none'."
81
+ )
82
+ return cast(CorrectionScope, normalized)
83
+
84
+
85
+ def normalize_rd_zero_variance(policy: str) -> RDZeroVariancePolicy:
86
+ """Normalize the policy for RD studies with zero raw sampling variance."""
87
+
88
+ if not isinstance(policy, str):
89
+ raise UnsupportedMethodError("rd_zero_variance must be 'correct' or 'exclude'.")
90
+ normalized = policy.lower().replace("-", "_")
91
+ if normalized not in {"correct", "exclude"}:
92
+ raise UnsupportedMethodError("rd_zero_variance must be 'correct' or 'exclude'.")
93
+ return cast(RDZeroVariancePolicy, normalized)
94
+
95
+
96
+ def validate_correction(value: float | None, *, name: str) -> float:
97
+ if value is None:
98
+ return 0.0
99
+ if isinstance(value, bool) or not isinstance(value, Real):
100
+ raise InvalidStudyDataError(f"{name} must be a non-negative number or None.")
101
+ numeric = float(value)
102
+ if not np.isfinite(numeric) or numeric < 0.0:
103
+ raise InvalidStudyDataError(f"{name} must be finite and non-negative.")
104
+ return numeric
105
+
106
+
107
+ def _missing_reason(names: list[str]) -> str:
108
+ return "missing " + ", ".join(names)
109
+
110
+
111
+ def normalize_binary_studies(
112
+ *,
113
+ data: pd.DataFrame | None,
114
+ event_treat: ColumnOrArray,
115
+ n_treat: ColumnOrArray,
116
+ event_control: ColumnOrArray,
117
+ n_control: ColumnOrArray,
118
+ study: ColumnOrArray | None,
119
+ missing: MissingPolicy,
120
+ ) -> BinaryStudies:
121
+ """Resolve and validate two-group binary study counts."""
122
+
123
+ if data is not None and not isinstance(data, pd.DataFrame):
124
+ raise InvalidStudyDataError("data must be a pandas DataFrame or None.")
125
+ if missing not in {"raise", "drop"}:
126
+ raise InvalidStudyDataError("missing must be either 'raise' or 'drop'.")
127
+
128
+ arguments = {
129
+ "event_treat": event_treat,
130
+ "n_treat": n_treat,
131
+ "event_control": event_control,
132
+ "n_control": n_control,
133
+ }
134
+ raw = {
135
+ name: _resolve_vector(value, data=data, name=name)
136
+ for name, value in arguments.items()
137
+ }
138
+ lengths = {len(values) for values in raw.values()}
139
+ if len(lengths) != 1:
140
+ detail = ", ".join(f"{name}={len(values)}" for name, values in raw.items())
141
+ raise InvalidStudyDataError(
142
+ f"Binary count inputs must have equal lengths; {detail}."
143
+ )
144
+ length = lengths.pop()
145
+ if data is not None and len(data) != length:
146
+ raise InvalidStudyDataError(
147
+ "Array-like binary inputs used with data must have exactly one value "
148
+ "per DataFrame row."
149
+ )
150
+ labels = _study_labels(study, data=data, length=length)
151
+
152
+ try:
153
+ values = {
154
+ name: np.asarray(vector, dtype=np.float64) for name, vector in raw.items()
155
+ }
156
+ except (TypeError, ValueError) as error:
157
+ raise InvalidStudyDataError(
158
+ "Binary counts must contain numeric values."
159
+ ) from error
160
+
161
+ missing_by_name = {name: pd.isna(vector) for name, vector in values.items()}
162
+ any_missing = np.logical_or.reduce(tuple(missing_by_name.values()))
163
+ if np.any(any_missing) and missing == "raise":
164
+ rows = np.flatnonzero(any_missing).tolist()
165
+ raise InvalidStudyDataError(
166
+ f"Missing binary counts at row positions {rows}; use missing='drop' "
167
+ "to exclude them explicitly."
168
+ )
169
+
170
+ for name, vector in values.items():
171
+ present = ~missing_by_name[name]
172
+ invalid_finite = present & ~np.isfinite(vector)
173
+ if np.any(invalid_finite):
174
+ rows = np.flatnonzero(invalid_finite).tolist()
175
+ raise InvalidStudyDataError(f"{name} must be finite; invalid rows: {rows}.")
176
+ noninteger = present & (vector != np.floor(vector))
177
+ if np.any(noninteger):
178
+ rows = np.flatnonzero(noninteger).tolist()
179
+ raise InvalidStudyDataError(
180
+ f"{name} must contain whole-number counts; invalid rows: {rows}."
181
+ )
182
+
183
+ active = ~any_missing
184
+ for total_name in ("n_treat", "n_control"):
185
+ invalid = active & (values[total_name] <= 0.0)
186
+ if np.any(invalid):
187
+ rows = np.flatnonzero(invalid).tolist()
188
+ raise InvalidStudyDataError(
189
+ f"{total_name} must be strictly positive; invalid rows: {rows}."
190
+ )
191
+ for event_name, total_name in (
192
+ ("event_treat", "n_treat"),
193
+ ("event_control", "n_control"),
194
+ ):
195
+ invalid = active & (
196
+ (values[event_name] < 0.0) | (values[event_name] > values[total_name])
197
+ )
198
+ if np.any(invalid):
199
+ rows = np.flatnonzero(invalid).tolist()
200
+ raise InvalidStudyDataError(
201
+ f"{event_name} must be between 0 and {total_name}; "
202
+ f"invalid rows: {rows}."
203
+ )
204
+
205
+ reasons = np.full(length, None, dtype=object)
206
+ for index in np.flatnonzero(any_missing):
207
+ names = [name for name, mask in missing_by_name.items() if mask[index]]
208
+ reasons[index] = _missing_reason(names)
209
+ if not np.any(active):
210
+ raise InvalidStudyDataError(
211
+ "No studies remain after applying the missing-value policy."
212
+ )
213
+
214
+ return BinaryStudies(
215
+ row_id=np.arange(length, dtype=np.int64),
216
+ study=labels,
217
+ event_treat=values["event_treat"],
218
+ n_treat=values["n_treat"],
219
+ event_control=values["event_control"],
220
+ n_control=values["n_control"],
221
+ included=active,
222
+ exclusion_reason=reasons,
223
+ )
224
+
225
+
226
+ def correction_mask(
227
+ a: NDArray[np.float64],
228
+ b: NDArray[np.float64],
229
+ c: NDArray[np.float64],
230
+ d: NDArray[np.float64],
231
+ *,
232
+ included: NDArray[np.bool_],
233
+ scope: CorrectionScope,
234
+ ) -> NDArray[np.bool_]:
235
+ zero_study: NDArray[np.bool_] = np.asarray(
236
+ (a == 0.0) | (b == 0.0) | (c == 0.0) | (d == 0.0),
237
+ dtype=np.bool_,
238
+ )
239
+ if scope == "only_zero_studies":
240
+ return included & zero_study
241
+ if scope == "all_studies":
242
+ return included.copy()
243
+ if scope == "if_any_zero":
244
+ return (
245
+ included.copy()
246
+ if np.any(included & zero_study)
247
+ else np.zeros_like(included)
248
+ )
249
+ return np.zeros_like(included)
250
+
251
+
252
+ def adjusted_tables(
253
+ studies: BinaryStudies,
254
+ *,
255
+ correction: float,
256
+ scope: CorrectionScope,
257
+ ) -> tuple[
258
+ NDArray[np.float64],
259
+ NDArray[np.float64],
260
+ NDArray[np.float64],
261
+ NDArray[np.float64],
262
+ NDArray[np.bool_],
263
+ ]:
264
+ a = studies.event_treat.copy()
265
+ b = studies.n_treat - studies.event_treat
266
+ c = studies.event_control.copy()
267
+ d = studies.n_control - studies.event_control
268
+ corrected = correction_mask(a, b, c, d, included=studies.included, scope=scope) & (
269
+ correction > 0.0
270
+ )
271
+ if np.any(corrected):
272
+ for cell in (a, b, c, d):
273
+ cell[corrected] += correction
274
+ return a, b, c, d, corrected
275
+
276
+
277
+ def calculate_binary_effects(
278
+ studies: BinaryStudies,
279
+ *,
280
+ measure: str,
281
+ continuity_correction: float,
282
+ correction_scope: CorrectionScope,
283
+ rd_zero_variance: str = "correct",
284
+ ) -> BinaryEffectData:
285
+ """Calculate OR, RR, or RD effects and sampling variances."""
286
+
287
+ normalized_measure = measure.upper()
288
+ if normalized_measure not in {"OR", "RR", "RD"}:
289
+ raise UnsupportedMethodError("measure must be 'OR', 'RR', or 'RD'.")
290
+ rd_policy = normalize_rd_zero_variance(rd_zero_variance)
291
+ if normalized_measure != "RD" and rd_policy != "correct":
292
+ raise UnsupportedMethodError(
293
+ "rd_zero_variance is only configurable when measure='RD'."
294
+ )
295
+
296
+ included = studies.included.copy()
297
+ reasons = studies.exclusion_reason.copy()
298
+ rd_zero_variance_mask = np.zeros_like(included)
299
+ if normalized_measure == "RD":
300
+ raw_rd_variance = (
301
+ studies.event_treat
302
+ * (studies.n_treat - studies.event_treat)
303
+ / studies.n_treat**3
304
+ + studies.event_control
305
+ * (studies.n_control - studies.event_control)
306
+ / studies.n_control**3
307
+ )
308
+ rd_zero_variance_mask = np.asarray(
309
+ included & (raw_rd_variance == 0.0), dtype=np.bool_
310
+ )
311
+ if rd_policy == "exclude":
312
+ included[rd_zero_variance_mask] = False
313
+ for index in np.flatnonzero(rd_zero_variance_mask):
314
+ reasons[index] = "zero uncorrected risk-difference variance"
315
+
316
+ if normalized_measure in {"OR", "RR"}:
317
+ double_zero = (studies.event_treat == 0.0) & (studies.event_control == 0.0)
318
+ double_all = (studies.event_treat == studies.n_treat) & (
319
+ studies.event_control == studies.n_control
320
+ )
321
+ uninformative = included & (double_zero | double_all)
322
+ included[uninformative] = False
323
+ for index in np.flatnonzero(uninformative):
324
+ reasons[index] = (
325
+ "no events in either group"
326
+ if double_zero[index]
327
+ else "all participants have events in both groups"
328
+ )
329
+
330
+ working_studies = BinaryStudies(
331
+ row_id=studies.row_id,
332
+ study=studies.study,
333
+ event_treat=studies.event_treat,
334
+ n_treat=studies.n_treat,
335
+ event_control=studies.event_control,
336
+ n_control=studies.n_control,
337
+ included=included,
338
+ exclusion_reason=reasons,
339
+ )
340
+ if not np.any(included):
341
+ raise InvalidStudyDataError(
342
+ f"No informative studies remain for measure={normalized_measure!r}."
343
+ )
344
+
345
+ a, b, c, d, corrected = adjusted_tables(
346
+ working_studies,
347
+ correction=continuity_correction,
348
+ scope=correction_scope,
349
+ )
350
+ zero_after_correction = included & (
351
+ (a == 0.0) | (b == 0.0) | (c == 0.0) | (d == 0.0)
352
+ )
353
+ if normalized_measure in {"OR", "RR"} and np.any(zero_after_correction):
354
+ rows = np.flatnonzero(zero_after_correction).tolist()
355
+ raise InvalidStudyDataError(
356
+ "OR/RR study effects are undefined with remaining zero cells at row "
357
+ f"positions {rows}; use a positive continuity_correction."
358
+ )
359
+
360
+ effect = np.full(len(included), np.nan, dtype=np.float64)
361
+ variance = np.full(len(included), np.nan, dtype=np.float64)
362
+ active = included
363
+ if normalized_measure == "OR":
364
+ effect[active] = np.log((a[active] * d[active]) / (b[active] * c[active]))
365
+ variance[active] = (
366
+ 1.0 / a[active] + 1.0 / b[active] + 1.0 / c[active] + 1.0 / d[active]
367
+ )
368
+ effect_scale = "log"
369
+ display_scale = "exp"
370
+ elif normalized_measure == "RR":
371
+ n1 = a + b
372
+ n2 = c + d
373
+ effect[active] = np.log((a[active] / n1[active]) / (c[active] / n2[active]))
374
+ variance[active] = (
375
+ 1.0 / a[active] - 1.0 / n1[active] + 1.0 / c[active] - 1.0 / n2[active]
376
+ )
377
+ effect_scale = "log"
378
+ display_scale = "exp"
379
+ else:
380
+ # RD remains on the uncorrected natural scale. Corrected counts are used
381
+ # only for its sampling variance when zero cells would make it degenerate.
382
+ effect[active] = (
383
+ studies.event_treat[active] / studies.n_treat[active]
384
+ - studies.event_control[active] / studies.n_control[active]
385
+ )
386
+ n1 = a + b
387
+ n2 = c + d
388
+ variance[active] = (
389
+ a[active] * b[active] / n1[active] ** 3
390
+ + c[active] * d[active] / n2[active] ** 3
391
+ )
392
+ effect_scale = "identity"
393
+ display_scale = "identity"
394
+
395
+ invalid_variance = active & (~np.isfinite(variance) | (variance <= 0.0))
396
+ if np.any(invalid_variance):
397
+ rows = np.flatnonzero(invalid_variance).tolist()
398
+ raise InvalidStudyDataError(
399
+ f"Non-positive binary sampling variance at row positions {rows}; "
400
+ "use an appropriate positive continuity_correction."
401
+ )
402
+
403
+ return BinaryEffectData(
404
+ studies=working_studies,
405
+ effect=effect,
406
+ variance=variance,
407
+ corrected=corrected,
408
+ rd_zero_variance=rd_zero_variance_mask,
409
+ measure=normalized_measure,
410
+ effect_scale=effect_scale,
411
+ display_scale=display_scale,
412
+ )
@@ -0,0 +1,271 @@
1
+ """Effect sizes and validation for two independent continuous groups.
2
+
3
+ The standardized mean difference follows ``metafor::escalc(measure="SMD")``:
4
+ the group mean difference is divided by the pooled standard deviation, then
5
+ multiplied by the exact Hedges correction. Its default ``LS`` sampling
6
+ variance is the large-sample approximation from Hedges (1982).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+ from numpy.typing import NDArray
16
+ from scipy.special import gammaln
17
+
18
+ from ..data import ColumnOrArray, MissingPolicy, _resolve_vector, _study_labels
19
+ from ..exceptions import InvalidStudyDataError, UnsupportedMethodError
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class ContinuousStudies:
24
+ """Validated two-group summary statistics and inclusion metadata."""
25
+
26
+ row_id: NDArray[np.int64]
27
+ study: NDArray[np.object_]
28
+ mean_treat: NDArray[np.float64]
29
+ sd_treat: NDArray[np.float64]
30
+ n_treat: NDArray[np.float64]
31
+ mean_control: NDArray[np.float64]
32
+ sd_control: NDArray[np.float64]
33
+ n_control: NDArray[np.float64]
34
+ included: NDArray[np.bool_]
35
+ exclusion_reason: NDArray[np.object_]
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class ContinuousEffectData:
40
+ """Continuous study effects on their model scale."""
41
+
42
+ studies: ContinuousStudies
43
+ effect: NDArray[np.float64]
44
+ variance: NDArray[np.float64]
45
+ pooled_sd: NDArray[np.float64]
46
+ cohen_d: NDArray[np.float64]
47
+ correction_factor: NDArray[np.float64]
48
+ measure: str
49
+ effect_scale: str = "identity"
50
+ display_scale: str = "identity"
51
+
52
+ @property
53
+ def included_effect(self) -> NDArray[np.float64]:
54
+ return self.effect[self.studies.included]
55
+
56
+ @property
57
+ def included_variance(self) -> NDArray[np.float64]:
58
+ return self.variance[self.studies.included]
59
+
60
+
61
+ def _missing_reason(names: list[str]) -> str:
62
+ return "missing " + ", ".join(names)
63
+
64
+
65
+ def normalize_continuous_studies(
66
+ *,
67
+ data: pd.DataFrame | None,
68
+ mean_treat: ColumnOrArray,
69
+ sd_treat: ColumnOrArray,
70
+ n_treat: ColumnOrArray,
71
+ mean_control: ColumnOrArray,
72
+ sd_control: ColumnOrArray,
73
+ n_control: ColumnOrArray,
74
+ study: ColumnOrArray | None,
75
+ missing: MissingPolicy,
76
+ ) -> ContinuousStudies:
77
+ """Resolve and validate two-group continuous summary statistics."""
78
+
79
+ if data is not None and not isinstance(data, pd.DataFrame):
80
+ raise InvalidStudyDataError("data must be a pandas DataFrame or None.")
81
+ if missing not in {"raise", "drop"}:
82
+ raise InvalidStudyDataError("missing must be either 'raise' or 'drop'.")
83
+
84
+ arguments = {
85
+ "mean_treat": mean_treat,
86
+ "sd_treat": sd_treat,
87
+ "n_treat": n_treat,
88
+ "mean_control": mean_control,
89
+ "sd_control": sd_control,
90
+ "n_control": n_control,
91
+ }
92
+ raw = {
93
+ name: _resolve_vector(value, data=data, name=name)
94
+ for name, value in arguments.items()
95
+ }
96
+ lengths = {len(values) for values in raw.values()}
97
+ if len(lengths) != 1:
98
+ detail = ", ".join(f"{name}={len(values)}" for name, values in raw.items())
99
+ raise InvalidStudyDataError(
100
+ f"Continuous inputs must have equal lengths; {detail}."
101
+ )
102
+ length = lengths.pop()
103
+ if data is not None and len(data) != length:
104
+ raise InvalidStudyDataError(
105
+ "Array-like continuous inputs used with data must have exactly one "
106
+ "value per DataFrame row."
107
+ )
108
+ labels = _study_labels(study, data=data, length=length)
109
+
110
+ try:
111
+ values = {
112
+ name: np.asarray(vector, dtype=np.float64) for name, vector in raw.items()
113
+ }
114
+ except (TypeError, ValueError) as error:
115
+ raise InvalidStudyDataError(
116
+ "Continuous inputs must contain numeric values."
117
+ ) from error
118
+
119
+ missing_by_name = {name: pd.isna(vector) for name, vector in values.items()}
120
+ any_missing = np.logical_or.reduce(tuple(missing_by_name.values()))
121
+ if np.any(any_missing) and missing == "raise":
122
+ rows = np.flatnonzero(any_missing).tolist()
123
+ raise InvalidStudyDataError(
124
+ f"Missing continuous inputs at row positions {rows}; use "
125
+ "missing='drop' to exclude them explicitly."
126
+ )
127
+
128
+ for name, vector in values.items():
129
+ present = ~missing_by_name[name]
130
+ invalid = present & ~np.isfinite(vector)
131
+ if np.any(invalid):
132
+ rows = np.flatnonzero(invalid).tolist()
133
+ raise InvalidStudyDataError(f"{name} must be finite; invalid rows: {rows}.")
134
+
135
+ active = ~any_missing
136
+ for name in ("n_treat", "n_control"):
137
+ vector = values[name]
138
+ noninteger = active & (vector != np.floor(vector))
139
+ if np.any(noninteger):
140
+ rows = np.flatnonzero(noninteger).tolist()
141
+ raise InvalidStudyDataError(
142
+ f"{name} must contain whole-number sample sizes; invalid rows: {rows}."
143
+ )
144
+ too_small = active & (vector < 2.0)
145
+ if np.any(too_small):
146
+ rows = np.flatnonzero(too_small).tolist()
147
+ raise InvalidStudyDataError(
148
+ f"{name} must be at least 2 when a sample SD is supplied; "
149
+ f"invalid rows: {rows}."
150
+ )
151
+
152
+ for name in ("sd_treat", "sd_control"):
153
+ invalid = active & (values[name] < 0.0)
154
+ if np.any(invalid):
155
+ rows = np.flatnonzero(invalid).tolist()
156
+ raise InvalidStudyDataError(
157
+ f"{name} must be non-negative; invalid rows: {rows}."
158
+ )
159
+
160
+ reasons = np.full(length, None, dtype=object)
161
+ for index in np.flatnonzero(any_missing):
162
+ names = [name for name, mask in missing_by_name.items() if mask[index]]
163
+ reasons[index] = _missing_reason(names)
164
+ if not np.any(active):
165
+ raise InvalidStudyDataError(
166
+ "No studies remain after applying the missing-value policy."
167
+ )
168
+
169
+ return ContinuousStudies(
170
+ row_id=np.arange(length, dtype=np.int64),
171
+ study=labels,
172
+ mean_treat=values["mean_treat"],
173
+ sd_treat=values["sd_treat"],
174
+ n_treat=values["n_treat"],
175
+ mean_control=values["mean_control"],
176
+ sd_control=values["sd_control"],
177
+ n_control=values["n_control"],
178
+ included=active,
179
+ exclusion_reason=reasons,
180
+ )
181
+
182
+
183
+ def _exact_hedges_correction(df: NDArray[np.float64]) -> NDArray[np.float64]:
184
+ """Return the exact gamma-function correction J(df)."""
185
+
186
+ return np.exp(
187
+ gammaln(df / 2.0) - 0.5 * np.log(df / 2.0) - gammaln((df - 1.0) / 2.0)
188
+ )
189
+
190
+
191
+ def calculate_continuous_effects(
192
+ studies: ContinuousStudies,
193
+ *,
194
+ measure: str,
195
+ smd_variance: str = "LS",
196
+ ) -> ContinuousEffectData:
197
+ """Calculate mean differences or exact-corrected Hedges' g values."""
198
+
199
+ normalized_measure = measure.upper()
200
+ if normalized_measure not in {"MD", "SMD"}:
201
+ raise UnsupportedMethodError("measure must be 'MD' or 'SMD'.")
202
+ normalized_variance = smd_variance.upper().replace("-", "_")
203
+ if normalized_variance in {"LARGE_SAMPLE", "HEDGES_1982"}:
204
+ normalized_variance = "LS"
205
+ if normalized_measure == "SMD" and normalized_variance != "LS":
206
+ raise UnsupportedMethodError(
207
+ "smd_variance currently supports only 'LS' (the Hedges 1982 "
208
+ "large-sample approximation)."
209
+ )
210
+
211
+ included = studies.included
212
+ row_count = len(included)
213
+ effect = np.full(row_count, np.nan, dtype=np.float64)
214
+ variance = np.full(row_count, np.nan, dtype=np.float64)
215
+ pooled_sd = np.full(row_count, np.nan, dtype=np.float64)
216
+ cohen_d = np.full(row_count, np.nan, dtype=np.float64)
217
+ correction_factor = np.full(row_count, np.nan, dtype=np.float64)
218
+
219
+ difference = studies.mean_treat - studies.mean_control
220
+ if normalized_measure == "MD":
221
+ effect[included] = difference[included]
222
+ variance[included] = (
223
+ studies.sd_treat[included] ** 2 / studies.n_treat[included]
224
+ + studies.sd_control[included] ** 2 / studies.n_control[included]
225
+ )
226
+ else:
227
+ degrees_freedom = studies.n_treat + studies.n_control - 2.0
228
+ pooled_variance = (
229
+ (studies.n_treat - 1.0) * studies.sd_treat**2
230
+ + (studies.n_control - 1.0) * studies.sd_control**2
231
+ ) / degrees_freedom
232
+ invalid_pooled = included & (
233
+ ~np.isfinite(pooled_variance) | (pooled_variance <= 0.0)
234
+ )
235
+ if np.any(invalid_pooled):
236
+ rows = np.flatnonzero(invalid_pooled).tolist()
237
+ raise InvalidStudyDataError(
238
+ "SMD requires a finite, strictly positive pooled SD; invalid "
239
+ f"rows: {rows}."
240
+ )
241
+
242
+ pooled_sd[included] = np.sqrt(pooled_variance[included])
243
+ cohen_d[included] = difference[included] / pooled_sd[included]
244
+ correction_factor[included] = _exact_hedges_correction(
245
+ degrees_freedom[included]
246
+ )
247
+ effect[included] = correction_factor[included] * cohen_d[included]
248
+ total_n = studies.n_treat + studies.n_control
249
+ variance[included] = (
250
+ 1.0 / studies.n_treat[included]
251
+ + 1.0 / studies.n_control[included]
252
+ + effect[included] ** 2 / (2.0 * total_n[included])
253
+ )
254
+
255
+ invalid_variance = included & (~np.isfinite(variance) | (variance <= 0.0))
256
+ if np.any(invalid_variance):
257
+ rows = np.flatnonzero(invalid_variance).tolist()
258
+ raise InvalidStudyDataError(
259
+ "Continuous sampling variances must be finite and strictly positive; "
260
+ f"invalid rows: {rows}."
261
+ )
262
+
263
+ return ContinuousEffectData(
264
+ studies=studies,
265
+ effect=effect,
266
+ variance=variance,
267
+ pooled_sd=pooled_sd,
268
+ cohen_d=cohen_d,
269
+ correction_factor=correction_factor,
270
+ measure=normalized_measure,
271
+ )
@@ -0,0 +1,14 @@
1
+ """Numerical estimators used by the public meta-analysis API."""
2
+
3
+ from .inverse_variance import InverseVarianceFit, fit_inverse_variance
4
+ from .mantel_haenszel import MantelHaenszelFit, fit_mantel_haenszel
5
+ from .tau2 import Tau2Estimate, estimate_tau2
6
+
7
+ __all__ = [
8
+ "InverseVarianceFit",
9
+ "MantelHaenszelFit",
10
+ "Tau2Estimate",
11
+ "estimate_tau2",
12
+ "fit_inverse_variance",
13
+ "fit_mantel_haenszel",
14
+ ]