pyplasmode 1.0.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.
- pyplasmode/__init__.py +178 -0
- pyplasmode/_validation.py +29 -0
- pyplasmode/data.py +221 -0
- pyplasmode/evaluate.py +392 -0
- pyplasmode/generate.py +349 -0
- pyplasmode/nominations.py +623 -0
- pyplasmode/outcomes.py +709 -0
- pyplasmode/py.typed +0 -0
- pyplasmode/results.py +172 -0
- pyplasmode/structures.py +123 -0
- pyplasmode/truth.py +510 -0
- pyplasmode-1.0.0.dist-info/METADATA +142 -0
- pyplasmode-1.0.0.dist-info/RECORD +15 -0
- pyplasmode-1.0.0.dist-info/WHEEL +4 -0
- pyplasmode-1.0.0.dist-info/licenses/LICENSE +29 -0
pyplasmode/outcomes.py
ADDED
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
"""Calibrated native-unit outcome specifications and generators."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from itertools import pairwise
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from numpy.typing import NDArray
|
|
12
|
+
from scipy.optimize import brentq
|
|
13
|
+
from scipy.special import expit
|
|
14
|
+
|
|
15
|
+
type ParameterValue = float | str | tuple[float, ...]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OutcomeSpecificationError(ValueError):
|
|
19
|
+
"""Raised when observable outcome targets are invalid or ambiguous."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CalibrationError(RuntimeError):
|
|
23
|
+
"""Raised when valid targets cannot be represented by the documented model."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RealizationError(RuntimeError):
|
|
27
|
+
"""Raised when a generated sample exceeds its sampling-error tolerance."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, order=True, slots=True)
|
|
31
|
+
class Duration:
|
|
32
|
+
"""A positive native-time duration resolved to days."""
|
|
33
|
+
|
|
34
|
+
days: float
|
|
35
|
+
label: str
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
if not np.isfinite(self.days) or self.days <= 0.0:
|
|
39
|
+
raise OutcomeSpecificationError("duration must be finite and positive")
|
|
40
|
+
if not self.label:
|
|
41
|
+
raise OutcomeSpecificationError("duration label must be non-empty")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def duration(value: float, unit: Literal["days", "weeks", "years"]) -> Duration:
|
|
45
|
+
"""Create a positive duration in days, weeks or years."""
|
|
46
|
+
factors = {"days": 1.0, "weeks": 7.0, "years": 365.25}
|
|
47
|
+
if not np.isfinite(value) or value <= 0.0:
|
|
48
|
+
raise OutcomeSpecificationError("duration value must be finite and positive")
|
|
49
|
+
if unit not in factors:
|
|
50
|
+
raise OutcomeSpecificationError("duration unit must be days, weeks or years")
|
|
51
|
+
return Duration(float(value) * factors[unit], f"{value:g} {unit}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def days(value: float) -> Duration:
|
|
55
|
+
"""Create a duration measured in days."""
|
|
56
|
+
return duration(value, "days")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def years(value: float) -> Duration:
|
|
60
|
+
"""Create a duration measured in years using 365.25 days per year."""
|
|
61
|
+
return duration(value, "years")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class BinaryOutcome:
|
|
66
|
+
"""A marginal binary probability and one effect per signal standard deviation."""
|
|
67
|
+
|
|
68
|
+
probability: float
|
|
69
|
+
odds_ratio: float | None = None
|
|
70
|
+
risk_ratio: float | None = None
|
|
71
|
+
risk_difference: float | None = None
|
|
72
|
+
|
|
73
|
+
def __post_init__(self) -> None:
|
|
74
|
+
if not 0.0 < self.probability < 1.0:
|
|
75
|
+
raise OutcomeSpecificationError("binary probability must be between zero and one")
|
|
76
|
+
effects = (self.odds_ratio, self.risk_ratio, self.risk_difference)
|
|
77
|
+
if sum(effect is not None for effect in effects) != 1:
|
|
78
|
+
raise OutcomeSpecificationError("BinaryOutcome requires exactly one effect measure")
|
|
79
|
+
for ratio in (self.odds_ratio, self.risk_ratio):
|
|
80
|
+
if ratio is not None and (not np.isfinite(ratio) or ratio <= 0.0):
|
|
81
|
+
raise OutcomeSpecificationError("binary ratio effects must be finite and positive")
|
|
82
|
+
if self.risk_difference is not None and not np.isfinite(self.risk_difference):
|
|
83
|
+
raise OutcomeSpecificationError("risk_difference must be finite")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True, slots=True)
|
|
87
|
+
class ContinuousOutcome:
|
|
88
|
+
"""A marginal mean and spread with an effect or variance-explained target."""
|
|
89
|
+
|
|
90
|
+
mean: float
|
|
91
|
+
standard_deviation: float
|
|
92
|
+
effect: float | None = None
|
|
93
|
+
variance_explained: float | None = None
|
|
94
|
+
|
|
95
|
+
def __post_init__(self) -> None:
|
|
96
|
+
if not np.isfinite(self.mean):
|
|
97
|
+
raise OutcomeSpecificationError("continuous mean must be finite")
|
|
98
|
+
if not np.isfinite(self.standard_deviation) or self.standard_deviation <= 0.0:
|
|
99
|
+
raise OutcomeSpecificationError("continuous standard_deviation must be positive")
|
|
100
|
+
if (self.effect is None) == (self.variance_explained is None):
|
|
101
|
+
raise OutcomeSpecificationError(
|
|
102
|
+
"ContinuousOutcome requires exactly one of effect or variance_explained"
|
|
103
|
+
)
|
|
104
|
+
if self.effect is not None:
|
|
105
|
+
if not np.isfinite(self.effect):
|
|
106
|
+
raise OutcomeSpecificationError("continuous effect must be finite")
|
|
107
|
+
if abs(self.effect) >= self.standard_deviation:
|
|
108
|
+
raise OutcomeSpecificationError(
|
|
109
|
+
"continuous effect variance must be smaller than total variance"
|
|
110
|
+
)
|
|
111
|
+
if self.variance_explained is not None and not 0.0 <= self.variance_explained < 1.0:
|
|
112
|
+
raise OutcomeSpecificationError("variance_explained must be in [0, 1)")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True, slots=True)
|
|
116
|
+
class CountOutcome:
|
|
117
|
+
"""A rate per exposure and a conditional count variance-to-mean ratio."""
|
|
118
|
+
|
|
119
|
+
rate: float
|
|
120
|
+
rate_ratio: float
|
|
121
|
+
exposure: float | tuple[float, ...] = 1.0
|
|
122
|
+
variance_to_mean: float = 1.0
|
|
123
|
+
|
|
124
|
+
def __post_init__(self) -> None:
|
|
125
|
+
if not np.isfinite(self.rate) or self.rate <= 0.0:
|
|
126
|
+
raise OutcomeSpecificationError("count rate must be finite and positive")
|
|
127
|
+
if not np.isfinite(self.rate_ratio) or self.rate_ratio <= 0.0:
|
|
128
|
+
raise OutcomeSpecificationError("rate_ratio must be finite and positive")
|
|
129
|
+
exposure = np.asarray(self.exposure, dtype=float)
|
|
130
|
+
if exposure.ndim > 1 or not exposure.size or not np.isfinite(exposure).all():
|
|
131
|
+
raise OutcomeSpecificationError("exposure must be a finite scalar or vector")
|
|
132
|
+
if np.any(exposure <= 0.0):
|
|
133
|
+
raise OutcomeSpecificationError("exposure must be positive")
|
|
134
|
+
if not np.isfinite(self.variance_to_mean) or self.variance_to_mean < 1.0:
|
|
135
|
+
raise OutcomeSpecificationError("variance_to_mean must be at least one")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True, slots=True)
|
|
139
|
+
class OrdinalOutcome:
|
|
140
|
+
"""Ordered category probabilities with one common odds ratio."""
|
|
141
|
+
|
|
142
|
+
categories: tuple[str, ...]
|
|
143
|
+
probabilities: tuple[float, ...]
|
|
144
|
+
common_odds_ratio: float
|
|
145
|
+
|
|
146
|
+
def __post_init__(self) -> None:
|
|
147
|
+
if len(self.categories) < 2 or len(set(self.categories)) != len(self.categories):
|
|
148
|
+
raise OutcomeSpecificationError("ordinal categories must be ordered and unique")
|
|
149
|
+
if len(self.probabilities) != len(self.categories):
|
|
150
|
+
raise OutcomeSpecificationError("ordinal probabilities require one value per category")
|
|
151
|
+
if any(not np.isfinite(value) or value <= 0.0 for value in self.probabilities):
|
|
152
|
+
raise OutcomeSpecificationError("ordinal probabilities must be finite and positive")
|
|
153
|
+
if not np.isclose(sum(self.probabilities), 1.0, atol=1e-10):
|
|
154
|
+
raise OutcomeSpecificationError("ordinal probabilities must sum to one")
|
|
155
|
+
if not np.isfinite(self.common_odds_ratio) or self.common_odds_ratio <= 0.0:
|
|
156
|
+
raise OutcomeSpecificationError("common_odds_ratio must be finite and positive")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True, slots=True)
|
|
160
|
+
class RiskPoint:
|
|
161
|
+
"""One cumulative risk at a native-time horizon."""
|
|
162
|
+
|
|
163
|
+
horizon: Duration
|
|
164
|
+
probability: float
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _risk_points(values: Mapping[Duration, float], *, name: str) -> tuple[RiskPoint, ...]:
|
|
168
|
+
if not values:
|
|
169
|
+
raise OutcomeSpecificationError(f"{name} must contain at least one horizon")
|
|
170
|
+
points = tuple(
|
|
171
|
+
RiskPoint(horizon, float(probability)) for horizon, probability in values.items()
|
|
172
|
+
)
|
|
173
|
+
points = tuple(sorted(points, key=lambda point: point.horizon.days))
|
|
174
|
+
probabilities = tuple(point.probability for point in points)
|
|
175
|
+
if any(not 0.0 <= value < 1.0 for value in probabilities):
|
|
176
|
+
raise OutcomeSpecificationError(f"{name} probabilities must be in [0, 1)")
|
|
177
|
+
if any(right < left for left, right in pairwise(probabilities)):
|
|
178
|
+
raise OutcomeSpecificationError(f"{name} probabilities must be nondecreasing")
|
|
179
|
+
horizons = tuple(point.horizon.days for point in points)
|
|
180
|
+
if len(set(horizons)) != len(horizons):
|
|
181
|
+
raise OutcomeSpecificationError(f"{name} horizons must be unique")
|
|
182
|
+
return points
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True, slots=True, init=False)
|
|
186
|
+
class TimeToEventOutcome:
|
|
187
|
+
"""Cumulative event/censoring risks and a proportional hazard ratio."""
|
|
188
|
+
|
|
189
|
+
event_risks: tuple[RiskPoint, ...]
|
|
190
|
+
hazard_ratio: float
|
|
191
|
+
censoring_risks: tuple[RiskPoint, ...]
|
|
192
|
+
|
|
193
|
+
def __init__(
|
|
194
|
+
self,
|
|
195
|
+
event_risks: Mapping[Duration, float],
|
|
196
|
+
hazard_ratio: float,
|
|
197
|
+
censoring_risks: Mapping[Duration, float] | None = None,
|
|
198
|
+
) -> None:
|
|
199
|
+
events = _risk_points(event_risks, name="event_risks")
|
|
200
|
+
if not np.isfinite(hazard_ratio) or hazard_ratio <= 0.0:
|
|
201
|
+
raise OutcomeSpecificationError("hazard_ratio must be finite and positive")
|
|
202
|
+
censoring = (
|
|
203
|
+
_risk_points(censoring_risks, name="censoring_risks")
|
|
204
|
+
if censoring_risks is not None
|
|
205
|
+
else tuple(RiskPoint(point.horizon, 0.0) for point in events)
|
|
206
|
+
)
|
|
207
|
+
if tuple(point.horizon.days for point in censoring) != tuple(
|
|
208
|
+
point.horizon.days for point in events
|
|
209
|
+
):
|
|
210
|
+
raise OutcomeSpecificationError(
|
|
211
|
+
"censoring_risks must use the same horizons as event_risks"
|
|
212
|
+
)
|
|
213
|
+
object.__setattr__(self, "event_risks", events)
|
|
214
|
+
object.__setattr__(self, "hazard_ratio", float(hazard_ratio))
|
|
215
|
+
object.__setattr__(self, "censoring_risks", censoring)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
type OutcomeSpec = (
|
|
219
|
+
BinaryOutcome | ContinuousOutcome | CountOutcome | OrdinalOutcome | TimeToEventOutcome
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclass(frozen=True, slots=True)
|
|
224
|
+
class CalibrationCheck:
|
|
225
|
+
"""One target, expectation, realization and sampling-error tolerance."""
|
|
226
|
+
|
|
227
|
+
name: str
|
|
228
|
+
target: float
|
|
229
|
+
expected: float
|
|
230
|
+
realized: float
|
|
231
|
+
tolerance: float
|
|
232
|
+
within_tolerance: bool
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@dataclass(frozen=True, slots=True)
|
|
236
|
+
class CalibrationResult:
|
|
237
|
+
"""Outcome calibration family, solved parameters and native-unit checks."""
|
|
238
|
+
|
|
239
|
+
family: str
|
|
240
|
+
parameters: tuple[tuple[str, ParameterValue], ...]
|
|
241
|
+
checks: tuple[CalibrationCheck, ...]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _readonly(values: NDArray[np.generic]) -> NDArray[np.generic]:
|
|
245
|
+
result = np.array(values, copy=True)
|
|
246
|
+
result.setflags(write=False)
|
|
247
|
+
return result
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@dataclass(frozen=True, slots=True)
|
|
251
|
+
class BinaryGeneratedOutcome:
|
|
252
|
+
"""Generated binary labels and calibration."""
|
|
253
|
+
|
|
254
|
+
values: NDArray[np.int8]
|
|
255
|
+
calibration: CalibrationResult
|
|
256
|
+
|
|
257
|
+
def __post_init__(self) -> None:
|
|
258
|
+
object.__setattr__(self, "values", _readonly(self.values))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@dataclass(frozen=True, slots=True)
|
|
262
|
+
class ContinuousGeneratedOutcome:
|
|
263
|
+
"""Generated continuous values and calibration."""
|
|
264
|
+
|
|
265
|
+
values: NDArray[np.float64]
|
|
266
|
+
calibration: CalibrationResult
|
|
267
|
+
|
|
268
|
+
def __post_init__(self) -> None:
|
|
269
|
+
object.__setattr__(self, "values", _readonly(self.values))
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@dataclass(frozen=True, slots=True)
|
|
273
|
+
class CountGeneratedOutcome:
|
|
274
|
+
"""Generated counts, exposure and calibration."""
|
|
275
|
+
|
|
276
|
+
values: NDArray[np.int64]
|
|
277
|
+
exposure: NDArray[np.float64]
|
|
278
|
+
calibration: CalibrationResult
|
|
279
|
+
|
|
280
|
+
def __post_init__(self) -> None:
|
|
281
|
+
object.__setattr__(self, "values", _readonly(self.values))
|
|
282
|
+
object.__setattr__(self, "exposure", _readonly(self.exposure))
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@dataclass(frozen=True, slots=True)
|
|
286
|
+
class OrdinalGeneratedOutcome:
|
|
287
|
+
"""Generated ordinal codes, category labels and calibration."""
|
|
288
|
+
|
|
289
|
+
codes: NDArray[np.int64]
|
|
290
|
+
categories: tuple[str, ...]
|
|
291
|
+
calibration: CalibrationResult
|
|
292
|
+
|
|
293
|
+
def __post_init__(self) -> None:
|
|
294
|
+
object.__setattr__(self, "codes", _readonly(self.codes))
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@dataclass(frozen=True, slots=True)
|
|
298
|
+
class SurvivalGeneratedOutcome:
|
|
299
|
+
"""Observed follow-up, event state and latent generation times."""
|
|
300
|
+
|
|
301
|
+
time: NDArray[np.float64]
|
|
302
|
+
event: NDArray[np.bool_]
|
|
303
|
+
latent_event_time: NDArray[np.float64]
|
|
304
|
+
censoring_time: NDArray[np.float64]
|
|
305
|
+
calibration: CalibrationResult
|
|
306
|
+
|
|
307
|
+
def __post_init__(self) -> None:
|
|
308
|
+
object.__setattr__(self, "time", _readonly(self.time))
|
|
309
|
+
object.__setattr__(self, "event", _readonly(self.event))
|
|
310
|
+
object.__setattr__(self, "latent_event_time", _readonly(self.latent_event_time))
|
|
311
|
+
object.__setattr__(self, "censoring_time", _readonly(self.censoring_time))
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
type GeneratedOutcome = (
|
|
315
|
+
BinaryGeneratedOutcome
|
|
316
|
+
| ContinuousGeneratedOutcome
|
|
317
|
+
| CountGeneratedOutcome
|
|
318
|
+
| OrdinalGeneratedOutcome
|
|
319
|
+
| SurvivalGeneratedOutcome
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _signal(values: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
324
|
+
signal = np.asarray(values, dtype=np.float64)
|
|
325
|
+
if signal.ndim != 1 or not signal.size or not np.isfinite(signal).all():
|
|
326
|
+
raise CalibrationError("signal must be a finite non-empty one-dimensional array")
|
|
327
|
+
spread = float(np.std(signal))
|
|
328
|
+
if spread == 0.0:
|
|
329
|
+
return np.zeros_like(signal)
|
|
330
|
+
return (signal - np.mean(signal)) / spread
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _probability_tolerance(target: float, size: int) -> float:
|
|
334
|
+
return float(max(6.0 * np.sqrt(target * (1.0 - target) / size), 6.0 / size))
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _check(
|
|
338
|
+
name: str, target: float, expected: float, realized: float, tolerance: float
|
|
339
|
+
) -> CalibrationCheck:
|
|
340
|
+
if not np.isclose(expected, target, rtol=1e-8, atol=1e-10):
|
|
341
|
+
raise CalibrationError(f"{name} expectation {expected:g} does not match target {target:g}")
|
|
342
|
+
within = abs(realized - target) <= tolerance
|
|
343
|
+
if not within:
|
|
344
|
+
raise RealizationError(
|
|
345
|
+
f"{name} realization {realized:g} differs from target {target:g} "
|
|
346
|
+
f"beyond tolerance {tolerance:g}"
|
|
347
|
+
)
|
|
348
|
+
return CalibrationCheck(name, target, expected, realized, tolerance, True)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _logit_intercept(offset: NDArray[np.float64], target: float) -> float:
|
|
352
|
+
"""Solve a marginal probability without silently widening the supported model."""
|
|
353
|
+
try:
|
|
354
|
+
return float(
|
|
355
|
+
brentq(lambda value: float(np.mean(expit(value + offset)) - target), -50.0, 50.0)
|
|
356
|
+
)
|
|
357
|
+
except ValueError as error:
|
|
358
|
+
raise CalibrationError(
|
|
359
|
+
"requested probability is outside the logit calibration bracket"
|
|
360
|
+
) from error
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _solve_monotone(function: object, target_name: str) -> float:
|
|
364
|
+
if not callable(function):
|
|
365
|
+
raise TypeError("calibration function must be callable")
|
|
366
|
+
upper = 1.0
|
|
367
|
+
while function(upper) > 0.0 and upper < 1e6:
|
|
368
|
+
upper *= 2.0
|
|
369
|
+
try:
|
|
370
|
+
return float(brentq(function, 0.0, upper, xtol=1e-12, rtol=1e-12))
|
|
371
|
+
except ValueError as error:
|
|
372
|
+
raise CalibrationError(f"could not bracket {target_name} calibration") from error
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _generate_binary(
|
|
376
|
+
spec: BinaryOutcome, z: NDArray[np.float64], rng: np.random.Generator
|
|
377
|
+
) -> BinaryGeneratedOutcome:
|
|
378
|
+
if spec.odds_ratio is not None:
|
|
379
|
+
beta = float(np.log(spec.odds_ratio))
|
|
380
|
+
intercept = _logit_intercept(beta * z, spec.probability)
|
|
381
|
+
probabilities = expit(intercept + beta * z)
|
|
382
|
+
family = "bernoulli_logit"
|
|
383
|
+
effect_name = "odds_ratio"
|
|
384
|
+
effect_value = spec.odds_ratio
|
|
385
|
+
elif spec.risk_ratio is not None:
|
|
386
|
+
beta = float(np.log(spec.risk_ratio))
|
|
387
|
+
intercept = float(np.log(spec.probability / np.mean(np.exp(beta * z))))
|
|
388
|
+
probabilities = np.exp(intercept + beta * z)
|
|
389
|
+
family = "bernoulli_log"
|
|
390
|
+
effect_name = "risk_ratio"
|
|
391
|
+
effect_value = spec.risk_ratio
|
|
392
|
+
else:
|
|
393
|
+
if spec.risk_difference is None:
|
|
394
|
+
raise RuntimeError("binary effect is missing")
|
|
395
|
+
beta = float(spec.risk_difference)
|
|
396
|
+
intercept = float(spec.probability - beta * np.mean(z))
|
|
397
|
+
probabilities = intercept + beta * z
|
|
398
|
+
family = "bernoulli_identity"
|
|
399
|
+
effect_name = "risk_difference"
|
|
400
|
+
effect_value = spec.risk_difference
|
|
401
|
+
if np.any((probabilities < 0.0) | (probabilities > 1.0)):
|
|
402
|
+
raise CalibrationError("binary conditional probabilities leave [0, 1]")
|
|
403
|
+
values = rng.binomial(1, probabilities).astype(np.int8)
|
|
404
|
+
expected = float(np.mean(probabilities))
|
|
405
|
+
realized = float(np.mean(values))
|
|
406
|
+
check = _check(
|
|
407
|
+
"probability",
|
|
408
|
+
spec.probability,
|
|
409
|
+
expected,
|
|
410
|
+
realized,
|
|
411
|
+
_probability_tolerance(spec.probability, values.size),
|
|
412
|
+
)
|
|
413
|
+
calibration = CalibrationResult(
|
|
414
|
+
family,
|
|
415
|
+
(("intercept", intercept), ("signal_coefficient", beta), (effect_name, effect_value)),
|
|
416
|
+
(check,),
|
|
417
|
+
)
|
|
418
|
+
return BinaryGeneratedOutcome(values, calibration)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _generate_continuous(
|
|
422
|
+
spec: ContinuousOutcome, z: NDArray[np.float64], rng: np.random.Generator
|
|
423
|
+
) -> ContinuousGeneratedOutcome:
|
|
424
|
+
if np.ptp(z) == 0.0 and (
|
|
425
|
+
spec.effect not in (None, 0.0) or spec.variance_explained not in (None, 0.0)
|
|
426
|
+
):
|
|
427
|
+
raise OutcomeSpecificationError(
|
|
428
|
+
"a constant signal cannot explain continuous outcome variance"
|
|
429
|
+
)
|
|
430
|
+
if z.size < 2:
|
|
431
|
+
raise OutcomeSpecificationError("continuous generation requires at least two observations")
|
|
432
|
+
if spec.effect is not None:
|
|
433
|
+
effect = float(spec.effect)
|
|
434
|
+
residual_sd = float(np.sqrt(spec.standard_deviation**2 - effect**2))
|
|
435
|
+
target_r2 = (effect / spec.standard_deviation) ** 2
|
|
436
|
+
else:
|
|
437
|
+
if spec.variance_explained is None:
|
|
438
|
+
raise RuntimeError("continuous effect is missing")
|
|
439
|
+
target_r2 = float(spec.variance_explained)
|
|
440
|
+
effect = float(spec.standard_deviation * np.sqrt(target_r2))
|
|
441
|
+
residual_sd = float(spec.standard_deviation * np.sqrt(1.0 - target_r2))
|
|
442
|
+
values = spec.mean + effect * z + rng.normal(0.0, residual_sd, size=z.size)
|
|
443
|
+
mean_tolerance = 6.0 * spec.standard_deviation / np.sqrt(z.size)
|
|
444
|
+
sd_tolerance = 6.0 * spec.standard_deviation / np.sqrt(2.0 * (z.size - 1))
|
|
445
|
+
checks = (
|
|
446
|
+
_check("mean", spec.mean, spec.mean, float(np.mean(values)), mean_tolerance),
|
|
447
|
+
_check(
|
|
448
|
+
"standard_deviation",
|
|
449
|
+
spec.standard_deviation,
|
|
450
|
+
spec.standard_deviation,
|
|
451
|
+
float(np.std(values)),
|
|
452
|
+
sd_tolerance,
|
|
453
|
+
),
|
|
454
|
+
)
|
|
455
|
+
calibration = CalibrationResult(
|
|
456
|
+
"gaussian",
|
|
457
|
+
(
|
|
458
|
+
("intercept", spec.mean),
|
|
459
|
+
("signal_coefficient", effect),
|
|
460
|
+
("residual_standard_deviation", residual_sd),
|
|
461
|
+
("variance_explained", target_r2),
|
|
462
|
+
),
|
|
463
|
+
checks,
|
|
464
|
+
)
|
|
465
|
+
return ContinuousGeneratedOutcome(np.asarray(values, dtype=np.float64), calibration)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _exposure(spec: CountOutcome, size: int) -> NDArray[np.float64]:
|
|
469
|
+
values = np.asarray(spec.exposure, dtype=np.float64)
|
|
470
|
+
if values.ndim == 0:
|
|
471
|
+
return np.full(size, float(values), dtype=np.float64)
|
|
472
|
+
if values.shape != (size,):
|
|
473
|
+
raise OutcomeSpecificationError("exposure vector length must equal signal length")
|
|
474
|
+
return values
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _generate_count(
|
|
478
|
+
spec: CountOutcome, z: NDArray[np.float64], rng: np.random.Generator
|
|
479
|
+
) -> CountGeneratedOutcome:
|
|
480
|
+
exposure = _exposure(spec, z.size)
|
|
481
|
+
beta = float(np.log(spec.rate_ratio))
|
|
482
|
+
multiplier = np.exp(beta * z)
|
|
483
|
+
intercept = float(np.log(spec.rate * np.sum(exposure) / np.sum(exposure * multiplier)))
|
|
484
|
+
means = exposure * np.exp(intercept + beta * z)
|
|
485
|
+
if spec.variance_to_mean == 1.0:
|
|
486
|
+
values = rng.poisson(means).astype(np.int64)
|
|
487
|
+
dispersion = 0.0
|
|
488
|
+
variance = means
|
|
489
|
+
family = "poisson_log"
|
|
490
|
+
else:
|
|
491
|
+
dispersion = float(spec.variance_to_mean - 1.0)
|
|
492
|
+
shape = means / dispersion
|
|
493
|
+
latent_means = rng.gamma(shape=shape, scale=dispersion)
|
|
494
|
+
values = rng.poisson(latent_means).astype(np.int64)
|
|
495
|
+
variance = spec.variance_to_mean * means
|
|
496
|
+
family = "negative_binomial_nb1_log"
|
|
497
|
+
expected_rate = float(np.sum(means) / np.sum(exposure))
|
|
498
|
+
realized_rate = float(np.sum(values) / np.sum(exposure))
|
|
499
|
+
tolerance = float(6.0 * np.sqrt(np.sum(variance)) / np.sum(exposure))
|
|
500
|
+
check = _check("rate", spec.rate, expected_rate, realized_rate, tolerance)
|
|
501
|
+
calibration = CalibrationResult(
|
|
502
|
+
family,
|
|
503
|
+
(
|
|
504
|
+
("intercept", intercept),
|
|
505
|
+
("signal_coefficient", beta),
|
|
506
|
+
("rate_ratio", spec.rate_ratio),
|
|
507
|
+
("negative_binomial_dispersion", dispersion),
|
|
508
|
+
("conditional_variance_to_mean", spec.variance_to_mean),
|
|
509
|
+
),
|
|
510
|
+
(check,),
|
|
511
|
+
)
|
|
512
|
+
return CountGeneratedOutcome(values, exposure, calibration)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _generate_ordinal(
|
|
516
|
+
spec: OrdinalOutcome, z: NDArray[np.float64], rng: np.random.Generator
|
|
517
|
+
) -> OrdinalGeneratedOutcome:
|
|
518
|
+
beta = float(np.log(spec.common_odds_ratio))
|
|
519
|
+
cumulative_targets = np.cumsum(spec.probabilities)[:-1]
|
|
520
|
+
thresholds = tuple(_logit_intercept(-beta * z, float(target)) for target in cumulative_targets)
|
|
521
|
+
if any(right <= left for left, right in pairwise(thresholds)):
|
|
522
|
+
raise CalibrationError("calibrated ordinal thresholds are not strictly ordered")
|
|
523
|
+
conditional_cdf = np.column_stack([expit(value - beta * z) for value in thresholds])
|
|
524
|
+
uniforms = rng.random(z.size)
|
|
525
|
+
codes = np.sum(uniforms[:, None] > conditional_cdf, axis=1).astype(np.int64)
|
|
526
|
+
expected_probabilities = np.diff(
|
|
527
|
+
np.concatenate(([0.0], np.mean(conditional_cdf, axis=0), [1.0]))
|
|
528
|
+
)
|
|
529
|
+
realized_probabilities = np.bincount(codes, minlength=len(spec.categories)) / codes.size
|
|
530
|
+
checks = tuple(
|
|
531
|
+
_check(
|
|
532
|
+
f"category_probability:{category}",
|
|
533
|
+
target,
|
|
534
|
+
float(expected),
|
|
535
|
+
float(realized),
|
|
536
|
+
_probability_tolerance(target, codes.size),
|
|
537
|
+
)
|
|
538
|
+
for category, target, expected, realized in zip(
|
|
539
|
+
spec.categories,
|
|
540
|
+
spec.probabilities,
|
|
541
|
+
expected_probabilities,
|
|
542
|
+
realized_probabilities,
|
|
543
|
+
strict=True,
|
|
544
|
+
)
|
|
545
|
+
)
|
|
546
|
+
calibration = CalibrationResult(
|
|
547
|
+
"proportional_odds_logit",
|
|
548
|
+
(
|
|
549
|
+
("thresholds", thresholds),
|
|
550
|
+
("signal_coefficient", beta),
|
|
551
|
+
("common_odds_ratio", spec.common_odds_ratio),
|
|
552
|
+
),
|
|
553
|
+
checks,
|
|
554
|
+
)
|
|
555
|
+
return OrdinalGeneratedOutcome(codes, spec.categories, calibration)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _cumulative_hazards(
|
|
559
|
+
points: tuple[RiskPoint, ...], multiplier: NDArray[np.float64]
|
|
560
|
+
) -> NDArray[np.float64]:
|
|
561
|
+
solved: list[float] = []
|
|
562
|
+
for point in points:
|
|
563
|
+
target_survival = 1.0 - point.probability
|
|
564
|
+
solved.append(
|
|
565
|
+
_solve_monotone(
|
|
566
|
+
lambda value, target=target_survival: float(
|
|
567
|
+
np.mean(np.exp(-value * multiplier)) - target
|
|
568
|
+
),
|
|
569
|
+
f"risk at {point.horizon.label}",
|
|
570
|
+
)
|
|
571
|
+
)
|
|
572
|
+
hazards = np.asarray(solved, dtype=np.float64)
|
|
573
|
+
if np.any(np.diff(hazards) < -1e-10):
|
|
574
|
+
raise CalibrationError("calibrated cumulative event hazards are not nondecreasing")
|
|
575
|
+
return hazards
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _interval_hazards(
|
|
579
|
+
horizons: NDArray[np.float64], cumulative: NDArray[np.float64]
|
|
580
|
+
) -> NDArray[np.float64]:
|
|
581
|
+
increments = np.diff(np.concatenate(([0.0], cumulative)))
|
|
582
|
+
widths = np.diff(np.concatenate(([0.0], horizons)))
|
|
583
|
+
rates = increments / widths
|
|
584
|
+
if np.any(rates < -1e-12) or not np.isfinite(rates).all():
|
|
585
|
+
raise CalibrationError("piecewise interval hazards must be finite and non-negative")
|
|
586
|
+
return np.maximum(rates, 0.0)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _invert_piecewise(
|
|
590
|
+
thresholds: NDArray[np.float64],
|
|
591
|
+
horizons: NDArray[np.float64],
|
|
592
|
+
cumulative: NDArray[np.float64],
|
|
593
|
+
) -> NDArray[np.float64]:
|
|
594
|
+
times = np.full(thresholds.size, np.inf, dtype=np.float64)
|
|
595
|
+
previous_hazard = 0.0
|
|
596
|
+
previous_time = 0.0
|
|
597
|
+
for horizon, cumulative_hazard in zip(horizons, cumulative, strict=True):
|
|
598
|
+
interval_rate = (cumulative_hazard - previous_hazard) / (horizon - previous_time)
|
|
599
|
+
selected = np.isinf(times) & (thresholds <= cumulative_hazard)
|
|
600
|
+
if interval_rate > 0.0:
|
|
601
|
+
times[selected] = (
|
|
602
|
+
previous_time + (thresholds[selected] - previous_hazard) / interval_rate
|
|
603
|
+
)
|
|
604
|
+
previous_hazard = cumulative_hazard
|
|
605
|
+
previous_time = horizon
|
|
606
|
+
return times
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _generate_survival(
|
|
610
|
+
spec: TimeToEventOutcome, z: NDArray[np.float64], rng: np.random.Generator
|
|
611
|
+
) -> SurvivalGeneratedOutcome:
|
|
612
|
+
beta = float(np.log(spec.hazard_ratio))
|
|
613
|
+
multiplier = np.exp(beta * z)
|
|
614
|
+
horizons = np.asarray([point.horizon.days for point in spec.event_risks], dtype=np.float64)
|
|
615
|
+
baseline_cumulative = _cumulative_hazards(spec.event_risks, multiplier)
|
|
616
|
+
baseline_intervals = _interval_hazards(horizons, baseline_cumulative)
|
|
617
|
+
censoring_cumulative = np.asarray(
|
|
618
|
+
[-np.log1p(-point.probability) for point in spec.censoring_risks], dtype=np.float64
|
|
619
|
+
)
|
|
620
|
+
censoring_intervals = _interval_hazards(horizons, censoring_cumulative)
|
|
621
|
+
|
|
622
|
+
event_threshold = rng.exponential(size=z.size) / multiplier
|
|
623
|
+
censor_threshold = rng.exponential(size=z.size)
|
|
624
|
+
latent_event_time = _invert_piecewise(event_threshold, horizons, baseline_cumulative)
|
|
625
|
+
censoring_time = _invert_piecewise(censor_threshold, horizons, censoring_cumulative)
|
|
626
|
+
administrative = float(horizons[-1])
|
|
627
|
+
event = (latent_event_time <= censoring_time) & (latent_event_time <= administrative)
|
|
628
|
+
observed_time = np.minimum(np.minimum(latent_event_time, censoring_time), administrative)
|
|
629
|
+
|
|
630
|
+
checks: list[CalibrationCheck] = []
|
|
631
|
+
for index, point in enumerate(spec.event_risks):
|
|
632
|
+
expected = float(1.0 - np.mean(np.exp(-baseline_cumulative[index] * multiplier)))
|
|
633
|
+
realized = float(np.mean(latent_event_time <= point.horizon.days))
|
|
634
|
+
checks.append(
|
|
635
|
+
_check(
|
|
636
|
+
f"latent_event_risk:{point.horizon.label}",
|
|
637
|
+
point.probability,
|
|
638
|
+
expected,
|
|
639
|
+
realized,
|
|
640
|
+
_probability_tolerance(point.probability, z.size),
|
|
641
|
+
)
|
|
642
|
+
)
|
|
643
|
+
for point in spec.censoring_risks:
|
|
644
|
+
expected = point.probability
|
|
645
|
+
realized = float(np.mean(censoring_time <= point.horizon.days))
|
|
646
|
+
checks.append(
|
|
647
|
+
_check(
|
|
648
|
+
f"censoring_risk:{point.horizon.label}",
|
|
649
|
+
point.probability,
|
|
650
|
+
expected,
|
|
651
|
+
realized,
|
|
652
|
+
_probability_tolerance(point.probability, z.size),
|
|
653
|
+
)
|
|
654
|
+
)
|
|
655
|
+
calibration = CalibrationResult(
|
|
656
|
+
"piecewise_exponential_proportional_hazards",
|
|
657
|
+
(
|
|
658
|
+
("signal_coefficient", beta),
|
|
659
|
+
("hazard_ratio", spec.hazard_ratio),
|
|
660
|
+
("baseline_cumulative_hazards", tuple(float(value) for value in baseline_cumulative)),
|
|
661
|
+
("baseline_interval_hazards", tuple(float(value) for value in baseline_intervals)),
|
|
662
|
+
("censoring_interval_hazards", tuple(float(value) for value in censoring_intervals)),
|
|
663
|
+
),
|
|
664
|
+
tuple(checks),
|
|
665
|
+
)
|
|
666
|
+
return SurvivalGeneratedOutcome(
|
|
667
|
+
observed_time,
|
|
668
|
+
np.asarray(event, dtype=np.bool_),
|
|
669
|
+
latent_event_time,
|
|
670
|
+
censoring_time,
|
|
671
|
+
calibration,
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def generate_outcome(
|
|
676
|
+
spec: OutcomeSpec,
|
|
677
|
+
signal: NDArray[np.float64],
|
|
678
|
+
rng: np.random.Generator,
|
|
679
|
+
) -> GeneratedOutcome:
|
|
680
|
+
"""Calibrate and draw an outcome from a finite one-dimensional signal.
|
|
681
|
+
|
|
682
|
+
Args:
|
|
683
|
+
spec: Native-unit outcome specification.
|
|
684
|
+
signal: Finite vector, standardised internally to mean zero and unit
|
|
685
|
+
population standard deviation; constant vectors represent no signal.
|
|
686
|
+
rng: Caller-owned NumPy generator.
|
|
687
|
+
|
|
688
|
+
Returns:
|
|
689
|
+
Typed outcome arrays and solved calibration parameters. Survival times
|
|
690
|
+
are in days and are administratively censored at the last horizon.
|
|
691
|
+
|
|
692
|
+
Raises:
|
|
693
|
+
OutcomeSpecificationError: Invalid targets, exposure dimensions, or a
|
|
694
|
+
positive continuous explained-variance request with constant signal.
|
|
695
|
+
CalibrationError: The chosen family cannot attain a requested target.
|
|
696
|
+
RealizationError: Finite-sample diagnostics exceed the declared tolerance.
|
|
697
|
+
"""
|
|
698
|
+
z = _signal(signal)
|
|
699
|
+
if isinstance(spec, BinaryOutcome):
|
|
700
|
+
return _generate_binary(spec, z, rng)
|
|
701
|
+
if isinstance(spec, ContinuousOutcome):
|
|
702
|
+
return _generate_continuous(spec, z, rng)
|
|
703
|
+
if isinstance(spec, CountOutcome):
|
|
704
|
+
return _generate_count(spec, z, rng)
|
|
705
|
+
if isinstance(spec, OrdinalOutcome):
|
|
706
|
+
return _generate_ordinal(spec, z, rng)
|
|
707
|
+
if isinstance(spec, TimeToEventOutcome):
|
|
708
|
+
return _generate_survival(spec, z, rng)
|
|
709
|
+
raise TypeError(f"unsupported outcome specification: {type(spec).__name__}")
|