robustmargin 0.1.1__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,465 @@
1
+ """Schema-valid fake results for **consumers' own tests**.
2
+
3
+ ``fake_fit``, ``fake_threshold`` and ``fake_ratio`` build deterministic, schema-valid
4
+ :class:`~robustmargin.Fit`, :class:`~robustmargin.Threshold` and :class:`~robustmargin.Ratio`
5
+ objects so a consumer can write and run its own report-rendering and serialisation tests without
6
+ fitting any data. They are **not** statistical estimates of anything: every one of them carries
7
+ a warning saying so, and nothing here should be read as a claim about what a real fit would
8
+ produce for the same inputs.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import dataclasses
14
+ from collections.abc import Mapping
15
+
16
+ from scipy.stats import binomtest
17
+
18
+ from robustmargin.empirical import BaselineRate, GridBreakPoint
19
+ from robustmargin.models import Covariance, Fit, Parameter
20
+ from robustmargin.ratio import Ratio
21
+ from robustmargin.threshold import Threshold
22
+ from robustmargin.types import (
23
+ Axis,
24
+ Cell,
25
+ Censoring,
26
+ Definition,
27
+ IntervalShape,
28
+ JSONValue,
29
+ Observations,
30
+ Status,
31
+ )
32
+ from robustmargin.validation import Diagnostics, MonotonicityCheck, check_monotonicity, diagnose
33
+
34
+ __all__ = ["fake_fit", "fake_ratio", "fake_threshold"]
35
+
36
+
37
+ class _Unset:
38
+ """Sentinel distinguishing "argument not passed" from an explicit ``None`` override for
39
+ ``fake_fit``'s ``diagnostics``/``monotonicity`` parameters: an explicit argument --
40
+ ``None`` included -- always overrides; only *omitting* the argument entirely means "compute
41
+ the real one"."""
42
+
43
+ def __repr__(self) -> str:
44
+ return "<unset>"
45
+
46
+
47
+ _UNSET = _Unset()
48
+
49
+
50
+ def _observations_from_cells(fit: Fit) -> Observations:
51
+ """Rebuild the :class:`~robustmargin.Observations` a fake ``fit`` "came from", so
52
+ :func:`~robustmargin.check_monotonicity` can run on it the same way it would on real data."""
53
+ return Observations.from_counts(
54
+ fit.axis,
55
+ severity=[c.severity for c in fit.cells],
56
+ successes=[c.successes for c in fit.cells],
57
+ trials=[c.trials for c in fit.cells],
58
+ outcome=fit.outcome,
59
+ direction=fit.direction,
60
+ )
61
+
62
+
63
+ _AXIS = Axis(name="severity", unit="unit", scale="log")
64
+ _FAKE_WARNING = (
65
+ "This is a fake object from robustmargin.testing, built for a consumer's own tests. It is "
66
+ "schema-valid, not a statistical estimate."
67
+ )
68
+ _CELLS: tuple[Cell, ...] = (
69
+ Cell(severity=0.0, successes=100, trials=100),
70
+ Cell(severity=1.0, successes=60, trials=100),
71
+ Cell(severity=2.0, successes=20, trials=100),
72
+ )
73
+ # fake_ratio builds its two thresholds from two separate fits on disjoint data (different
74
+ # severities entirely), not the same _CELLS reused twice, so a consumer's test never mistakes
75
+ # the pair for a ratio computed from one dataset against itself.
76
+ _CELLS_A: tuple[Cell, ...] = _CELLS
77
+ _CELLS_B: tuple[Cell, ...] = (
78
+ # severity=0.0 is an all-success control, like _CELLS_A's: 'upper' is fixed at exactly 1.0
79
+ # in _fake_ok_params_and_covariance, and a failing control combined with a fixed upper=1.0
80
+ # makes the likelihood zero on a log axis (Fit's own check).
81
+ Cell(severity=0.0, successes=100, trials=100),
82
+ Cell(severity=1.5, successes=50, trials=100),
83
+ Cell(severity=3.0, successes=10, trials=100),
84
+ )
85
+
86
+ # The 14 (status, censoring) combinations a Threshold allows, mirroring the invariants
87
+ # Threshold.__post_init__ enforces.
88
+ _ALLOWED_THRESHOLD_COMBINATIONS: frozenset[tuple[Status, Censoring]] = frozenset(
89
+ {(Status.OK, censoring) for censoring in Censoring}
90
+ | {
91
+ (status, censoring)
92
+ for status in (Status.SEPARATION, Status.NOT_CONVERGED)
93
+ for censoring in (Censoring.NONE, Censoring.RIGHT, Censoring.LEFT)
94
+ }
95
+ | {
96
+ (status, Censoring.NONE)
97
+ for status in (Status.UNREACHABLE, Status.FAILS_AT_BASELINE, Status.CONTROL_INCOMPATIBLE)
98
+ }
99
+ )
100
+
101
+ # UNREACHABLE and FAILS_AT_BASELINE are properties of a perfectly good fit (the target
102
+ # performance is out of the fitted curve's range, or the control already fails); SEPARATION,
103
+ # NOT_CONVERGED and CONTROL_INCOMPATIBLE are properties of the fit itself, so a threshold with
104
+ # one of those statuses is built on a fit carrying the same status.
105
+ _FIT_STATUS_FOR_THRESHOLD_STATUS: dict[Status, Status] = {
106
+ Status.OK: Status.OK,
107
+ Status.UNREACHABLE: Status.OK,
108
+ Status.FAILS_AT_BASELINE: Status.OK,
109
+ Status.SEPARATION: Status.SEPARATION,
110
+ Status.NOT_CONVERGED: Status.NOT_CONVERGED,
111
+ Status.CONTROL_INCOMPATIBLE: Status.CONTROL_INCOMPATIBLE,
112
+ }
113
+
114
+
115
+ def _fake_provenance(provenance: Mapping[str, JSONValue] | None) -> dict[str, JSONValue]:
116
+ return dict(provenance) if provenance is not None else {}
117
+
118
+
119
+ def _fake_ok_params_and_covariance() -> tuple[dict[str, Parameter], Covariance]:
120
+ params = {
121
+ "mu": Parameter(value=0.5, fixed=False),
122
+ "s": Parameter(value=0.3, fixed=False),
123
+ "upper": Parameter(value=1.0, fixed=True),
124
+ "lower": Parameter(value=0.0, fixed=True),
125
+ }
126
+ covariance = Covariance(names=("mu", "s"), matrix=((0.01, 0.0), (0.0, 0.02)))
127
+ return params, covariance
128
+
129
+
130
+ def _fake_ok_fit(
131
+ cells: tuple[Cell, ...],
132
+ *,
133
+ provenance: Mapping[str, JSONValue] | None = None,
134
+ diagnostics: Diagnostics | None | _Unset = _UNSET,
135
+ monotonicity: MonotonicityCheck | None | _Unset = _UNSET,
136
+ ) -> Fit:
137
+ params, covariance = _fake_ok_params_and_covariance()
138
+ base = Fit(
139
+ axis=_AXIS,
140
+ outcome="success",
141
+ direction="decreasing",
142
+ model="binomial",
143
+ link="probit",
144
+ params=params,
145
+ covariance=covariance,
146
+ log_likelihood=-12.5,
147
+ cells=cells,
148
+ status=Status.OK,
149
+ cluster_ids=None,
150
+ warnings=(_FAKE_WARNING,),
151
+ provenance=_fake_provenance(provenance),
152
+ )
153
+ # Mirrors a real fit_dose_response result -- diagnostics through diagnose() (status is OK
154
+ # here, so this never raises), monotonicity through check_monotonicity() on the same cells --
155
+ # unless the caller explicitly overrode either.
156
+ resolved_diagnostics = diagnose(base) if isinstance(diagnostics, _Unset) else diagnostics
157
+ resolved_monotonicity = (
158
+ check_monotonicity(_observations_from_cells(base))
159
+ if isinstance(monotonicity, _Unset)
160
+ else monotonicity
161
+ )
162
+ return dataclasses.replace(
163
+ base, diagnostics=resolved_diagnostics, monotonicity=resolved_monotonicity
164
+ )
165
+
166
+
167
+ def fake_fit(
168
+ *,
169
+ status: Status = Status.OK,
170
+ provenance: Mapping[str, JSONValue] | None = None,
171
+ diagnostics: Diagnostics | None | _Unset = _UNSET,
172
+ monotonicity: MonotonicityCheck | None | _Unset = _UNSET,
173
+ ) -> Fit:
174
+ """Build a deterministic, schema-valid :class:`~robustmargin.Fit`.
175
+
176
+ Parameters
177
+ ----------
178
+ status
179
+ The :class:`~robustmargin.Status` to build. Every member other than ``UNREACHABLE`` and
180
+ ``FAILS_AT_BASELINE`` is accepted (those describe a threshold, not a fit, and
181
+ :class:`~robustmargin.Fit` itself rejects them). When ``status`` is not ``OK``, ``params``,
182
+ ``covariance`` and ``log_likelihood`` are all ``None``, matching
183
+ :class:`~robustmargin.Fit`'s own invariant.
184
+ provenance
185
+ Stored on the result as given, or ``{}`` if not provided.
186
+ diagnostics
187
+ Mirrors a real fit: when omitted entirely, this is :func:`~robustmargin.diagnose` on the
188
+ built fit when ``status`` is ``OK``, or ``None`` otherwise (:func:`~robustmargin.diagnose`
189
+ itself requires ``status is Status.OK``). Passing an explicit value -- ``None`` included --
190
+ always overrides that and is stored as given; ``Fit`` itself still rejects a non-``None``
191
+ value when ``status`` is not ``OK`` (a failed fit never carries a number).
192
+ monotonicity
193
+ Mirrors a real fit, the same way ``diagnostics`` does: when omitted entirely, this is
194
+ :func:`~robustmargin.check_monotonicity` on the built fit's own cells, for *every*
195
+ ``status`` (a real :func:`~robustmargin.fit_dose_response` result is never ``None`` here
196
+ regardless of status). An explicit value -- ``None`` included -- always overrides.
197
+
198
+ Returns
199
+ -------
200
+ Fit
201
+ A binomial-probit fit on a neutral ``severity`` axis, with ``cluster_ids=None`` and
202
+ ``warnings`` naming it a fake.
203
+ """
204
+ if status is Status.OK:
205
+ return _fake_ok_fit(
206
+ _CELLS, provenance=provenance, diagnostics=diagnostics, monotonicity=monotonicity
207
+ )
208
+
209
+ base = Fit(
210
+ axis=_AXIS,
211
+ outcome="success",
212
+ direction="decreasing",
213
+ model="binomial",
214
+ link="probit",
215
+ params=None,
216
+ covariance=None,
217
+ log_likelihood=None,
218
+ cells=_CELLS,
219
+ status=status,
220
+ cluster_ids=None,
221
+ warnings=(_FAKE_WARNING,),
222
+ provenance=_fake_provenance(provenance),
223
+ )
224
+ resolved_diagnostics = None if isinstance(diagnostics, _Unset) else diagnostics
225
+ resolved_monotonicity = (
226
+ check_monotonicity(_observations_from_cells(base))
227
+ if isinstance(monotonicity, _Unset)
228
+ else monotonicity
229
+ )
230
+ return dataclasses.replace(
231
+ base, diagnostics=resolved_diagnostics, monotonicity=resolved_monotonicity
232
+ )
233
+
234
+
235
+ def fake_threshold(
236
+ *,
237
+ status: Status = Status.OK,
238
+ censoring: Censoring = Censoring.NONE,
239
+ provenance: Mapping[str, JSONValue] | None = None,
240
+ ) -> Threshold:
241
+ """Build a deterministic, schema-valid :class:`~robustmargin.Threshold`.
242
+
243
+ Parameters
244
+ ----------
245
+ status
246
+ The :class:`~robustmargin.Status` to build.
247
+ censoring
248
+ The :class:`~robustmargin.Censoring` to build.
249
+ provenance
250
+ Stored on the result as given, or ``{}`` if not provided.
251
+
252
+ Returns
253
+ -------
254
+ Threshold
255
+ A threshold on a neutral ``severity`` axis with ``dependence="independent"``, and
256
+ ``warnings`` naming it a fake.
257
+
258
+ Raises
259
+ ------
260
+ ValueError
261
+ If ``(status, censoring)`` is not one of the fourteen combinations a ``Threshold``
262
+ allows.
263
+ """
264
+ if (status, censoring) not in _ALLOWED_THRESHOLD_COMBINATIONS:
265
+ raise ValueError(
266
+ f"fake_threshold: status={status!r} with censoring={censoring!r} is not a "
267
+ "combination Threshold allows"
268
+ )
269
+
270
+ fit = fake_fit(status=_FIT_STATUS_FOR_THRESHOLD_STATUS[status])
271
+
272
+ value: float | None
273
+ lo: float | None
274
+ hi: float | None
275
+ interval_method: str
276
+
277
+ if status in (Status.UNREACHABLE, Status.FAILS_AT_BASELINE, Status.CONTROL_INCOMPATIBLE):
278
+ value, lo, hi = None, None, None
279
+ interval_method = "exact_bound"
280
+ elif censoring is Censoring.RIGHT:
281
+ value, lo, hi = None, 10.0, None
282
+ interval_method = "exact_bound"
283
+ elif censoring is Censoring.LEFT:
284
+ value, lo, hi = None, None, 0.01
285
+ interval_method = "exact_bound"
286
+ elif censoring is Censoring.OPEN_UPPER:
287
+ value, lo, hi = 0.05, 0.03, None
288
+ interval_method = "profile"
289
+ elif censoring is Censoring.OPEN_LOWER:
290
+ value, lo, hi = 0.05, None, 0.08
291
+ interval_method = "profile"
292
+ elif status is Status.OK:
293
+ value, lo, hi = 0.05, 0.03, 0.08
294
+ interval_method = "profile"
295
+ else:
296
+ # SEPARATION / NOT_CONVERGED with censoring NONE (the exact-bound fallback, with both
297
+ # sides happening to be available here).
298
+ value, lo, hi = None, 0.02, 0.09
299
+ interval_method = "exact_bound"
300
+
301
+ # The baseline and grid fields are set exactly where the invariants allow them, so a
302
+ # consumer testing against a fake sees the same shape a real result has. A fake that left
303
+ # them None on FAILS_AT_BASELINE would quietly stop exercising the field that status exists
304
+ # to carry.
305
+ baseline: BaselineRate | None = None
306
+ grid: GridBreakPoint | None = None
307
+ if status is Status.FAILS_AT_BASELINE:
308
+ # The two-sided exact interval for 98/200 at level=0.95, which is what
309
+ # `censoring._baseline_rate` produces for the same counts. Computed rather than
310
+ # transcribed, so the fake cannot drift from the real path.
311
+ _lo, _hi = binomtest(98, 200).proportion_ci(confidence_level=0.95, method="exact")
312
+ baseline = BaselineRate(
313
+ severity=0.0,
314
+ successes=98,
315
+ trials=200,
316
+ rate=0.49,
317
+ lo=float(_lo),
318
+ hi=float(_hi),
319
+ level=0.95,
320
+ )
321
+ elif status in (Status.SEPARATION, Status.NOT_CONVERGED):
322
+ grid = GridBreakPoint(0.05, 0.1, Censoring.NONE)
323
+
324
+ return Threshold(
325
+ axis=_AXIS,
326
+ definition=Definition.absolute(0.95),
327
+ direction="decreasing",
328
+ value=value,
329
+ lo=lo,
330
+ hi=hi,
331
+ level=0.95,
332
+ interval_method=interval_method,
333
+ censoring=censoring,
334
+ status=status,
335
+ dependence="independent",
336
+ fit=fit,
337
+ warnings=(_FAKE_WARNING,),
338
+ baseline=baseline,
339
+ grid=grid,
340
+ provenance=_fake_provenance(provenance),
341
+ )
342
+
343
+
344
+ def _fake_ok_threshold(*, fit: Fit, value: float, lo: float, hi: float) -> Threshold:
345
+ return Threshold(
346
+ axis=_AXIS,
347
+ definition=Definition.absolute(0.95),
348
+ direction="decreasing",
349
+ value=value,
350
+ lo=lo,
351
+ hi=hi,
352
+ level=0.95,
353
+ interval_method="profile",
354
+ censoring=Censoring.NONE,
355
+ status=Status.OK,
356
+ dependence="independent",
357
+ fit=fit,
358
+ warnings=(_FAKE_WARNING,),
359
+ provenance={},
360
+ )
361
+
362
+
363
+ def fake_ratio(
364
+ *,
365
+ shape: IntervalShape | None = None,
366
+ status: Status = Status.OK,
367
+ censoring: Censoring = Censoring.NONE,
368
+ provenance: Mapping[str, JSONValue] | None = None,
369
+ ) -> Ratio:
370
+ """Build a deterministic, schema-valid :class:`~robustmargin.Ratio`.
371
+
372
+ A failed or censored ratio carries no numbers: whenever ``status`` is not
373
+ ``OK`` or ``censoring`` is not ``NONE``, ``shape``, ``estimate``, ``lo`` and ``hi`` are all
374
+ ``None``, matching :class:`~robustmargin.Ratio`'s own invariant.
375
+
376
+ Parameters
377
+ ----------
378
+ shape
379
+ The :class:`~robustmargin.IntervalShape` to build. ``None`` (the default) means
380
+ ``BOUNDED`` when ``status`` is ``OK`` and ``censoring`` is ``NONE``, and means "no
381
+ shape" (forced ``None``) otherwise. Passing an explicit shape together with a non-``OK``
382
+ ``status`` or a non-``NONE`` ``censoring`` raises ``ValueError``, since that combination
383
+ can never be a valid ``Ratio``. Every member is buildable, including
384
+ ``IntervalShape.EXCLUSIVE``, which a real :func:`~robustmargin.ratio_interval` call cannot
385
+ produce while ``dependence="independent"`` but whose :class:`~robustmargin.Ratio` invariants
386
+ still need a consumer-testable fixture.
387
+ status
388
+ The :class:`~robustmargin.Status` to build.
389
+ censoring
390
+ The :class:`~robustmargin.Censoring` to build.
391
+ provenance
392
+ Stored on the result as given, or ``{}`` if not provided.
393
+
394
+ Returns
395
+ -------
396
+ Ratio
397
+ A ratio built from two fits on disjoint data (different cells entirely), with
398
+ ``dependence="independent"``, ``cluster_ids=None`` on both fits, and ``warnings``
399
+ naming it a fake. On the ``OK`` + ``NONE`` path, ``estimate`` is exactly
400
+ ``threshold_a.value / threshold_b.value``, matching
401
+ :class:`~robustmargin.Ratio`'s own invariant; the point estimate always lies inside the
402
+ Fieller confidence set described by ``shape``, including for ``EXCLUSIVE``.
403
+
404
+ Raises
405
+ ------
406
+ ValueError
407
+ If ``shape`` is given explicitly while ``status`` is not ``OK`` or ``censoring`` is not
408
+ ``NONE``.
409
+ """
410
+ is_ok_none = status is Status.OK and censoring is Censoring.NONE
411
+ if not is_ok_none and shape is not None:
412
+ raise ValueError(
413
+ f"fake_ratio: shape={shape!r} is not allowed when status={status!r} or "
414
+ f"censoring={censoring!r} -- a failed or censored ratio carries no shape"
415
+ )
416
+
417
+ value_a, lo_a, hi_a = 0.05, 0.03, 0.08
418
+ value_b, lo_b, hi_b = 0.1, 0.06, 0.16
419
+ threshold_a = _fake_ok_threshold(fit=_fake_ok_fit(_CELLS_A), value=value_a, lo=lo_a, hi=hi_a)
420
+ threshold_b = _fake_ok_threshold(fit=_fake_ok_fit(_CELLS_B), value=value_b, lo=lo_b, hi=hi_b)
421
+
422
+ resolved_shape: IntervalShape | None
423
+ estimate: float | None
424
+ lo: float | None
425
+ hi: float | None
426
+
427
+ if not is_ok_none:
428
+ resolved_shape, estimate, lo, hi = None, None, None, None
429
+ else:
430
+ resolved_shape = IntervalShape.BOUNDED if shape is None else shape
431
+ ratio_value = value_a / value_b
432
+ estimate = ratio_value
433
+ if resolved_shape is IntervalShape.BOUNDED:
434
+ lo, hi = 0.3, 0.7
435
+ elif resolved_shape is IntervalShape.UNBOUNDED:
436
+ lo, hi = None, None
437
+ elif resolved_shape is IntervalShape.HALF_OPEN:
438
+ # [lo, inf): lo <= estimate is Ratio's own invariant. This is a shape a real
439
+ # ratio_interval(method="fieller") call does produce -- it covers an open region of
440
+ # the parameter space -- unlike EXCLUSIVE below.
441
+ lo, hi = ratio_value * 0.5, None
442
+ else:
443
+ # EXCLUSIVE: unreachable through ratio_interval() itself while
444
+ # dependence="independent" fixes the covariance term at 0, but Ratio's own
445
+ # invariants for it stay enforced against v0.2's dependence="paired", and this fake
446
+ # builds one directly so a consumer can still test against it. The point estimate
447
+ # sits on the near edge of the excluded gap, inside the (-inf, lo] ray of the
448
+ # Fieller confidence set.
449
+ lo, hi = ratio_value, ratio_value + 1.0
450
+
451
+ return Ratio(
452
+ threshold_a=threshold_a,
453
+ threshold_b=threshold_b,
454
+ estimate=estimate,
455
+ lo=lo,
456
+ hi=hi,
457
+ shape=resolved_shape,
458
+ method="fieller",
459
+ dependence="independent",
460
+ level=0.95,
461
+ censoring=censoring,
462
+ status=status,
463
+ warnings=(_FAKE_WARNING,),
464
+ provenance=_fake_provenance(provenance),
465
+ )