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/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__}")