Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
737 changes: 733 additions & 4 deletions docs/changelog.rst

Large diffs are not rendered by default.

54 changes: 54 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,60 @@ module = [
'surpyval.univariate.parametric.distributions.geometric',
'surpyval.univariate.parametric.distributions.negative_binomial',
'surpyval.univariate.parametric.distributions.poisson',
'surpyval.univariate.parametric.distributions.custom_distribution',
'surpyval.univariate.parametric.distributions.exact_event_time',
'surpyval.univariate.parametric.distributions.normal',
'surpyval.univariate.parametric.distributions.lognormal',
'surpyval.univariate.parametric.distributions.logistic',
'surpyval.univariate.parametric.distributions.rayleigh',
'surpyval.univariate.parametric.distributions.beta',
'surpyval.univariate.parametric.distributions.beta4',
'surpyval.univariate.parametric.distributions.gamma',
'surpyval.univariate.parametric.distributions.gumbel',
'surpyval.univariate.parametric.distributions.gumbel_lev',
'surpyval.univariate.parametric.distributions.loglogistic',
'surpyval.univariate.parametric.distributions.exponential',
'surpyval.univariate.parametric.distributions.uniform',
'surpyval.univariate.parametric.distributions.degenerate',
'surpyval.univariate.parametric.distributions.expo_weibull',
'surpyval.univariate.parametric.fitters',
'surpyval.univariate.parametric.fitters.closed_form',
'surpyval.univariate.parametric.fitters.mle',
'surpyval.univariate.parametric.fitters.mpp',
'surpyval.univariate.parametric.fitters.mom',
'surpyval.univariate.parametric.fitters.mps',
'surpyval.univariate.parametric.fitters.mse',
'surpyval.univariate.parametric.probability_plotting',
'surpyval.univariate.parametric.discrete_fitter',
'surpyval.univariate.parametric.royston_parmar',
'surpyval.univariate.parametric.parametric_fitter',
'surpyval.univariate.parametric.parametric',
'surpyval.univariate.parametric.mixture_model',
'surpyval.univariate.parametric.distributions.fixed_event_probability',
# Already fully annotated; listed so they cannot slip back.
'surpyval.fit_best',
'surpyval.utils.recurrent_utils',
'surpyval.utils.score',
'surpyval.recurrent.tests',
'surpyval.recurrent.parametric.counting_process',
'surpyval.univariate.regression.regression_data',
'surpyval.univariate.regression.tvc_fit',
'surpyval.univariate.regression.frailty',
# Every accelerated-life module except general_log_linear, whose
# phi_param_map and phi_bounds are callables of the covariate
# dimension rather than the dict and tuple LifeModel declares. It is
# excluded from LIFE_MODELS for the same reason (#345).
'surpyval.univariate.regression.accelerated_life',
'surpyval.univariate.regression.accelerated_life.accelerated_life',
'surpyval.univariate.regression.accelerated_life.dual_exponential',
'surpyval.univariate.regression.accelerated_life.dual_power',
'surpyval.univariate.regression.accelerated_life.exponential',
'surpyval.univariate.regression.accelerated_life.eyring',
'surpyval.univariate.regression.accelerated_life.lifemodel',
'surpyval.univariate.regression.accelerated_life.linear',
'surpyval.univariate.regression.accelerated_life.parameter_substitution',
'surpyval.univariate.regression.accelerated_life.power',
'surpyval.univariate.regression.accelerated_life.power_exponential',
]
disallow_untyped_defs = true

Expand Down
8 changes: 4 additions & 4 deletions surpyval/recurrent/parametric/counting_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,17 @@ class CountingProcess(ABC):
"""

@abstractmethod
def iif(self, x: ArrayLike, *params) -> ArrayLike:
def iif(self, x: ArrayLike, *params: ArrayLike) -> ArrayLike:
"""Instantaneous intensity function (event rate) at ``x``."""
...

@abstractmethod
def cif(self, x: ArrayLike, *params) -> ArrayLike:
def cif(self, x: ArrayLike, *params: ArrayLike) -> ArrayLike:
"""Cumulative intensity (expected event count) by ``x``."""
...

@abstractmethod
def log_iif(self, x: ArrayLike, *params) -> ArrayLike:
def log_iif(self, x: ArrayLike, *params: ArrayLike) -> ArrayLike:
"""Natural logarithm of the instantaneous intensity at ``x``."""
...

Expand Down Expand Up @@ -66,7 +66,7 @@ class IntensityModel(CountingProcess):
"""

@abstractmethod
def inv_cif(self, N: ArrayLike, *params) -> ArrayLike:
def inv_cif(self, N: ArrayLike, *params: ArrayLike) -> ArrayLike:
"""Time by which ``N`` events are expected; the inverse of ``cif``."""
...

Expand Down
54 changes: 46 additions & 8 deletions surpyval/tests/univariate/parametric/test_binomial.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import pytest
from scipy.stats import binom

from surpyval import Bernoulli, Binomial, Parametric
from surpyval import Bernoulli, Binomial, FixedEventProbability, Parametric

N, P = 5, 0.3

Expand Down Expand Up @@ -94,17 +94,30 @@ def test_fit_with_counts():


def test_reduces_to_bernoulli_at_n_one():
# At n = 1 the event probabilities match the Bernoulli: P(K=1) = p and
# P(K=0) = 1 - p. Note surpyval's Bernoulli is a degenerate
# "fixed event probability" model whose survival is the constant
# probability of *no* event (1 - p), so it lines up with the binomial's
# P(K = 0) = ff(0), not its sf(0).
# At n = 1 the binomial *is* the Bernoulli, and since 0.19.1 the two
# agree exactly on the probability mass:
binomial = Binomial.from_params([1, P])
bernoulli = Bernoulli.from_params(P)
assert np.isclose(binomial.df(1), P)
assert np.isclose(binomial.df(0), 1 - P)
assert np.isclose(binomial.ff(0), bernoulli.sf(0))
assert np.isclose(binomial.sf(0), bernoulli.ff(0))
np.testing.assert_allclose(
np.asarray(bernoulli.df([0, 1]), dtype=float),
np.asarray(binomial.df([0, 1]), dtype=float),
)

# The survival functions are offset by one, and that is a convention
# rather than a disagreement. Binomial follows the package's discrete
# rule R(k) = P(K > k); Bernoulli uses R(x) = P(X >= x), so that
# R(0) = 1 and R(1) = p read as a one-shot device. Hence:
for x in (0, 1):
assert np.isclose(bernoulli.sf(x), binomial.sf(x - 1))

# Before 0.19.1 Bernoulli was a flat "fixed event probability" model
# with F(x) = p at every x, which lined up with neither. That model
# still exists under its own name and is unchanged.
fixed = FixedEventProbability.from_params(P)
assert np.isclose(fixed.ff(0), P)
assert np.isclose(fixed.ff(37.5), P)


@pytest.mark.parametrize(
Expand Down Expand Up @@ -139,3 +152,28 @@ def test_to_dict_roundtrip():
restored = Parametric.from_dict(model.to_dict())
assert np.allclose(restored.params, [N, P])
assert np.isclose(restored.mean(), N * P)


def test_support_brackets_the_outcomes_exclusively():
# ``support`` is a pair of exclusive bounds -- ``_validate_fit_inputs``
# rejects ``x <= support[0]`` and ``x >= support[1]`` -- so both must
# sit one step outside the outcomes {0, ..., n}. Zero events and n
# events are ordinary outcomes with real mass, and the bounds used to
# exclude both. Nothing observed it because Binomial does not inherit
# OptimisedFitMixin, where that check lives.
n_trials = 5
for model in (
Binomial.from_params([n_trials, 0.3]),
Binomial.fit([0, 2, 3, 5, 1], n_trials=n_trials),
):
lower, upper = model.support
for k in (0, n_trials):
assert lower < k < upper, k
assert Binomial.df(k, n_trials, 0.3) > 0


def test_class_level_support_admits_zero_events():
# The class-level bound is checked before n is known, so only its
# lower end is meaningful; it must still admit k = 0, as Poisson's
# does. It read 0 -- Geometric's value, whose first mass is at k = 1.
assert Binomial.support[0] < 0
188 changes: 188 additions & 0 deletions surpyval/tests/univariate/parametric/test_conditional_survival.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
"""
Numerical coverage for ``cs``, the conditional survival function.

Eleven distributions used to carry their own ``cs``, each with the same
one-line body as ``ParametricFitter.cs`` and a docstring example that
pinned its numbers. The bodies were duplication -- and duplication that
had already rotted, since Gamma's docstring stated the *exponential*
survival function above a body that computed the ratio correctly. The
overrides are gone; the numbers they pinned are here, so deleting the
docstrings did not delete the only per-distribution check on ``cs``.

Expected values are the ones those docstrings recorded, which the
doctest run verified on every supported interpreter.
"""

import numpy as np
import pytest

from surpyval import (
Beta,
Exponential,
ExpoWeibull,
Gamma,
LogLogistic,
LogNormal,
Normal,
Rayleigh,
Uniform,
Weibull,
)

X = np.array([1, 2, 3, 4, 5])

# (distribution, x, X, params, expected) -- lifted from the docstrings
# the overrides used to carry.
CASES = [
(
Beta,
np.array([0.1, 0.2, 0.3, 0.4, 0.5]),
0.4,
(3, 4),
[0.6315219, 0.32921811, 0.12946429, 0.03115814, 0.00233319],
),
(
ExpoWeibull,
X,
1,
(3, 4, 1.2),
[
8.77367129e-01,
4.25451775e-01,
5.09266354e-02,
5.37452200e-04,
1.35732908e-07,
],
),
(
Exponential,
X,
5,
(3,),
[
4.97870684e-02,
2.47875218e-03,
1.23409804e-04,
6.14421235e-06,
3.05902321e-07,
],
),
(
Gamma,
X,
5,
(3, 4),
[
2.59402488e-02,
6.39048747e-04,
1.51519143e-05,
3.48776510e-07,
7.79933496e-09,
],
),
(
LogLogistic,
X,
5,
(3, 4),
[0.51270879, 0.28444803, 0.16902083, 0.10629329, 0.07003273],
),
(
LogNormal,
X,
5,
(3, 4),
[0.97287811, 0.9496515, 0.92933892, 0.91129122, 0.89505592],
),
(
Normal,
X,
5,
(3, 4),
[0.73452116, 0.51421702, 0.34242113, 0.2165286, 0.1298356],
),
(
Rayleigh,
X,
5,
(3,),
[0.54274748, 0.26359714, 0.11455884, 0.04455143, 0.01550385],
),
(
Uniform,
X,
4,
(0, 10),
[0.83333333, 0.66666667, 0.5, 0.33333333, 0.16666667],
),
(
Weibull,
X,
5,
(3, 4),
[
2.52537548e-04,
3.00394073e-10,
2.45288508e-19,
1.48999440e-32,
5.42544000e-51,
],
),
]

IDS = [c[0].name for c in CASES]


@pytest.mark.parametrize("dist, x, cond, params, expected", CASES, ids=IDS)
def test_cs_matches_the_documented_values(dist, x, cond, params, expected):
# The docstrings printed eight decimal places, so a value like
# 0.01550385 pins the result to about 2e-7 relative -- atol carries
# the fixed-decimal entries, rtol the ones in scientific notation.
got = np.asarray(dist.cs(x, cond, *params), dtype=float)
np.testing.assert_allclose(got, np.array(expected), rtol=1e-6, atol=5e-9)


@pytest.mark.parametrize("dist, x, cond, params, expected", CASES, ids=IDS)
def test_cs_equals_the_survival_ratio(dist, x, cond, params, expected):
# The property the inherited implementation encodes. Exponential is
# included deliberately: its override returns sf(x) on the strength
# of memorylessness, and this is what checks that shortcut is the
# same function the others compute the long way.
got = np.asarray(dist.cs(x, cond, *params), dtype=float)
ratio = np.asarray(
dist.sf(x + cond, *params) / dist.sf(cond, *params), dtype=float
)
np.testing.assert_allclose(got, ratio, rtol=1e-9)


def test_cs_at_zero_is_one():
# Surviving a further nothing is certain, whatever has been survived.
for dist, _, cond, params, _ in CASES:
got = np.asarray(dist.cs(0.0, cond, *params), dtype=float)
np.testing.assert_allclose(got, 1.0, rtol=1e-9, atol=1e-12)


def test_exponential_cs_is_memoryless():
# The reason Exponential keeps an override. Conditioning on any
# amount of prior survival leaves the distribution unchanged.
x = np.array([0.5, 1.0, 2.0, 4.0])
base = np.asarray(Exponential.sf(x, 3), dtype=float)
for cond in (0.0, 1.0, 10.0, 100.0):
got = np.asarray(Exponential.cs(x, cond, 3), dtype=float)
np.testing.assert_allclose(got, base, rtol=1e-12)


def test_discrete_distributions_inherit_a_working_cs():
# These never defined cs and reach the base implementation. Before
# the base gained one they raised AttributeError.
from surpyval import Geometric, NegativeBinomial, Poisson

for dist, params in (
(Poisson, (3.0,)),
(Geometric, (0.3,)),
(NegativeBinomial, (2.0, 0.4)),
):
got = np.asarray(dist.cs(np.array([1, 2, 3]), 2, *params), dtype=float)
assert got.shape == (3,)
assert np.all(np.isfinite(got))
assert np.all((got >= 0) & (got <= 1 + 1e-12))
Loading
Loading