Source code for assetlife.lifetime_models._parametric_regressions

"""Lifetime regression

Notes
-----
This module contains two parametric lifetime regressions.
ProportionalHazard is not Cox regression (Cox is semiparametric).
"""

from __future__ import annotations

from abc import ABC
from collections.abc import Sequence
from typing import Any, Literal, Self, final
from typing_extensions import override

import numpy as np
import optype.numpy as onp
from numpydoc import docscrape  # pyright: ignore[reportMissingTypeStubs]
from scipy.optimize import Bounds

from ._base import (
    FittableParametricLifetimeModel,
    LifetimeData,
    LifetimeLikelihood,
    ParametricLifetimeModel,
    document_args,
)
from ._distributions import (
    Gamma,
    LifetimeDistribution,
    get_distrib_params_bounds,
    init_distrib_params_from_lifetimes,
)
from assetlife.base import FitConfig, FittingResults, ParametricModel
from assetlife.typing import CoercibleFloat64_ND, Float64_ND


class LinearCovarEffect(ParametricModel):
    """
    Covariates effect.

    Parameters
    ----------
    coefficients : tuple of float, default is (None,)
        Coefficients of the covariates effect.
    """

    def __init__(self, *coefficients: float):
        super().__init__(*coefficients)

    def g(self, *covar: CoercibleFloat64_ND) -> Float64_ND:
        """
        Returns the covariates effect.

        Parameters
        ----------
        covar : float or np.ndarray
            The covariate values

        Returns
        -------
        out : np.float64 or np.ndarray
        """
        nb_coef = self.get_params().size
        if len(covar) != nb_coef:
            raise ValueError(
                f"""
                Invalid number of covar. Got {nb_coef} coefficients but {len(covar)} covariates are given.
                """
            )
        broadcasted_covar = np.broadcast_arrays(*covar)
        stack_covar = np.stack(broadcasted_covar, axis=-1)
        return np.exp(np.sum(stack_covar * self.get_params(), axis=-1))

    def jac_g(self, *covar: CoercibleFloat64_ND) -> onp.ArrayND[np.float64]:
        """
        Returns the jacobian of the covariates effect.

        Parameters
        ----------
        covar : float or np.ndarray
            The covariate values

        Returns
        -------
        out : np.ndarray
        """
        g = self.g(*covar)
        broadcasted_covar = np.broadcast_arrays(*covar)
        stack_covar = np.stack(broadcasted_covar, axis=0)
        return stack_covar * g

    @override
    def __repr__(self) -> str:
        return f"LinearCovarEffect({self.get_params().item()!r})"


_covar_docstring = [
    docscrape.Parameter(
        "covar",
        "float or np.ndarray",
        [
            "Covariates values.",
            "float can only be valid if the regression has one coefficients.",
            "Otherwise it must be a ndarray of shape `(nb_coef,)` or `(m, nb_coef)`.",
        ],
    ),
]


class ParametricLifetimeRegression(
    FittableParametricLifetimeModel[*tuple[CoercibleFloat64_ND, ...]], ABC
):
    """
    Base class for lifetime regression.
    """

    baseline: LifetimeDistribution
    covar_effect: LinearCovarEffect
    fitting_results: FittingResults | None

    def __init__(
        self,
        baseline: LifetimeDistribution,
        coefficients: Sequence[float] = (),
    ):
        super().__init__()
        self.covar_effect = LinearCovarEffect(*coefficients)
        self.baseline = baseline

    def get_coefficients(self) -> onp.Array1D[np.float64]:
        """
        Returns the coefficients values.

        Returns
        -------
        out : ndarray
        """
        return self.covar_effect.get_params()

    @override
    @document_args(base_cls=ParametricLifetimeModel, args_docstring=_covar_docstring)
    def sf(
        self,
        time: CoercibleFloat64_ND,
        *covar: CoercibleFloat64_ND,
    ) -> Float64_ND:
        return super().sf(time, *covar)

    @override
    @document_args(base_cls=ParametricLifetimeModel, args_docstring=_covar_docstring)
    def isf(
        self,
        probability: CoercibleFloat64_ND,
        *covar: CoercibleFloat64_ND,
    ) -> Float64_ND:
        cumulative_hazard_rate = -np.log(
            np.clip(probability, 0, 1 - np.finfo(float).resolution)
        )
        return self.ichf(cumulative_hazard_rate, *covar)

    @override
    @document_args(base_cls=ParametricLifetimeModel, args_docstring=_covar_docstring)
    def cdf(
        self,
        time: CoercibleFloat64_ND,
        *covar: CoercibleFloat64_ND,
    ) -> Float64_ND:
        return super().cdf(time, *covar)

    @override
    @document_args(base_cls=ParametricLifetimeModel, args_docstring=_covar_docstring)
    def pdf(
        self,
        time: CoercibleFloat64_ND,
        *covar: CoercibleFloat64_ND,
    ) -> Float64_ND:
        return super().pdf(time, *covar)

    @override
    @document_args(base_cls=ParametricLifetimeModel, args_docstring=_covar_docstring)
    def ppf(
        self,
        probability: CoercibleFloat64_ND,
        *covar: CoercibleFloat64_ND,
    ) -> Float64_ND:
        return super().ppf(probability, *covar)

    @override
    @document_args(base_cls=ParametricLifetimeModel, args_docstring=_covar_docstring)
    def median(self, *covar: CoercibleFloat64_ND) -> Float64_ND:
        return super().median(*covar)

    @override
    @document_args(
        base_cls=FittableParametricLifetimeModel, args_docstring=_covar_docstring
    )
    def mean(self, *covar: CoercibleFloat64_ND) -> Float64_ND:
        return super().mean(*covar)

    @override
    @document_args(
        base_cls=FittableParametricLifetimeModel, args_docstring=_covar_docstring
    )
    def var(self, *covar: CoercibleFloat64_ND) -> Float64_ND:
        return super().var(*covar)

    @override
    @document_args(
        base_cls=FittableParametricLifetimeModel, args_docstring=_covar_docstring
    )
    def jac_sf(
        self,
        time: CoercibleFloat64_ND,
        *covar: CoercibleFloat64_ND,
    ) -> onp.ArrayND[np.float64]:
        return super().jac_sf(time, *covar)

    @override
    @document_args(
        base_cls=FittableParametricLifetimeModel, args_docstring=_covar_docstring
    )
    def jac_cdf(
        self,
        time: CoercibleFloat64_ND,
        *covar: CoercibleFloat64_ND,
    ) -> onp.ArrayND[np.float64]:
        return super().jac_cdf(time, *covar)

    @override
    @document_args(
        base_cls=FittableParametricLifetimeModel, args_docstring=_covar_docstring
    )
    def jac_pdf(
        self,
        time: CoercibleFloat64_ND,
        *covar: CoercibleFloat64_ND,
    ) -> onp.ArrayND[np.float64]:
        return super().jac_pdf(time, *covar)

    @override
    @document_args(base_cls=ParametricLifetimeModel, args_docstring=_covar_docstring)
    def rvs(
        self,
        size: int | tuple[int, ...] | None = None,
        *covar: CoercibleFloat64_ND,
        seed: int
        | np.random.Generator
        | np.random.BitGenerator
        | np.random.RandomState
        | None = None,
    ) -> Float64_ND:
        return super().rvs(
            size,
            *covar,
            seed=seed,
        )

    @override
    def init_likelihood(
        self,
        time: onp.Array1D[np.float64] | onp.Array[tuple[int, Literal[2]], np.float64],
        args: Sequence[onp.Array1D[np.float64]] | None = None,
        event: onp.Array1D[np.bool_] | None = None,
        entry: onp.Array1D[np.float64] | None = None,
        **kwargs: Any,
    ) -> LifetimeLikelihood:
        assert args is not None
        fresh_regression = type(self)(
            type(self.baseline)(), coefficients=(0.0,) * len(args)
        )  # init new regression object with appropriate number of covar
        lifetime_data = LifetimeData(time, event, entry, args)
        x0 = kwargs.get(
            "x0", init_regression_params_from_lifetimes(fresh_regression, lifetime_data)
        )
        fresh_regression.set_params(x0)
        config = FitConfig(x0)
        config.scipy_minimize_options["bounds"] = kwargs.get(
            "bounds", get_regression_params_bounds(fresh_regression)
        )
        config.scipy_minimize_options["method"] = kwargs.get("method", "L-BFGS-B")
        config.covariance_method = kwargs.get(
            "covariance_method",
            "2point" if isinstance(fresh_regression.baseline, Gamma) else "cs",
        )
        return LifetimeLikelihood(fresh_regression, lifetime_data, config)

    def fit(
        self,
        time: onp.Array1D[np.float64] | onp.Array[tuple[int, Literal[2]], np.float64],
        covar: onp.Array1D[np.float64] | Sequence[onp.Array1D[np.float64]],
        event: onp.Array1D[np.bool_] | None = None,
        entry: onp.Array1D[np.float64] | None = None,
        **kwargs: Any,
    ) -> Self:
        if not isinstance(covar, Sequence):
            covar = (covar,)
        optimizer = self.init_likelihood(
            time, args=covar, event=event, entry=entry, **kwargs
        )
        self.fitting_results = optimizer.optimize()
        self.covar_effect.set_params([0.0] * len(covar))  # modify nb coef inplace
        self.set_params(self.fitting_results.optimal_params)

        return self


def init_regression_params_from_lifetimes(
    model: ParametricLifetimeRegression, data: LifetimeData
) -> onp.Array1D[np.float64]:
    param0 = np.zeros_like(model.get_params(), dtype=np.float64)
    param0[-model.baseline.get_params().size :] = init_distrib_params_from_lifetimes(
        model.baseline, data
    )
    return param0


def get_regression_params_bounds(model: ParametricLifetimeRegression) -> Bounds:
    nb_coefficients = model.covar_effect.get_params().size
    lb = np.concatenate((
        np.full(nb_coefficients, -np.inf),
        get_distrib_params_bounds(
            model.baseline
        ).lb,  # baseline has _params_bounds according to typing
    ))
    ub = np.concatenate((
        np.full(nb_coefficients, np.inf),
        get_distrib_params_bounds(model.baseline).ub,
    ))
    return Bounds(lb, ub)


[docs] @final class ParametricProportionalHazard(ParametricLifetimeRegression): r""" Proportional Hazard regression. The cumulative hazard function :math:`H` is linked to the multiplier function :math:`g` by the relation: .. math:: H(t, x) = g(\beta, x) H_0(t) = e^{\beta \cdot x} H_0(t) where :math:`x` is a vector of covariates, :math:`\beta` is the coefficient vector of the effect of covariates, :math:`H_0` is the baseline cumulative hazard function [1]_. | Parameters ---------- baseline : FittableParametricLifetimeModel Any lifetime model that can be fitted. coefficients : tuple of floats (values can be None), default is (None,) Coefficients values of the covariate effects. Attributes ---------- baseline : FittableParametricLifetimeModel The regression baseline model (lifetime model). covar_effect : _CovarEffect The regression covariate effect. fitting_results : FittingResults, default is None An object containing fitting results (AIC, BIC, etc.). If the model is not fitted, the value is None. References ---------- .. [1] Sun, J. (2006). The statistical analysis of interval-censored failure time data (Vol. 3, No. 1). New York: springer. See Also -------- regression.AFT : Accelerated Failure Time regression. """
[docs] @override @document_args( base_cls=ParametricLifetimeRegression, args_docstring=_covar_docstring ) def hf( self, time: CoercibleFloat64_ND, *covar: CoercibleFloat64_ND, ) -> Float64_ND: return self.covar_effect.g(*covar) * self.baseline.hf(time)
[docs] @override @document_args( base_cls=ParametricLifetimeRegression, args_docstring=_covar_docstring ) def chf( self, time: CoercibleFloat64_ND, *covar: CoercibleFloat64_ND, ) -> Float64_ND: return self.covar_effect.g(*covar) * self.baseline.chf(time)
[docs] @override @document_args( base_cls=ParametricLifetimeRegression, args_docstring=_covar_docstring ) def ichf( self, cumulative_hazard_rate: CoercibleFloat64_ND, *covar: CoercibleFloat64_ND, ) -> Float64_ND: return self.baseline.ichf(cumulative_hazard_rate / self.covar_effect.g(*covar))
[docs] @override @document_args( base_cls=FittableParametricLifetimeModel, args_docstring=_covar_docstring ) def dhf( self, time: CoercibleFloat64_ND, *covar: CoercibleFloat64_ND, ) -> onp.ArrayND[np.float64]: return self.covar_effect.g(*covar) * self.baseline.dhf(time)
[docs] @override @document_args( base_cls=FittableParametricLifetimeModel, args_docstring=_covar_docstring ) def jac_hf( self, time: CoercibleFloat64_ND, *covar: CoercibleFloat64_ND, ) -> onp.ArrayND[np.float64]: ndtime, *ndcovar = np.broadcast_arrays(time, *covar) u = self.baseline.hf(ndtime) * self.covar_effect.jac_g( *ndcovar ) # (nb_coef, ...) v = self.covar_effect.g(*ndcovar) * self.baseline.jac_hf(ndtime) # (p, ...) return np.concatenate((u, v), axis=0) # (p + nb_coef, ...)
[docs] @override @document_args( base_cls=FittableParametricLifetimeModel, args_docstring=_covar_docstring ) def jac_chf( self, time: CoercibleFloat64_ND, *covar: CoercibleFloat64_ND, ) -> onp.ArrayND[np.float64]: ndtime, *ndcovar = np.broadcast_arrays(time, *covar) u = self.baseline.chf(ndtime) * self.covar_effect.jac_g( *ndcovar ) # (nb_coef, ...) v = self.covar_effect.g(*ndcovar) * self.baseline.jac_chf(ndtime) return np.concatenate((u, v), axis=0) # (p + nb_coef, ...)
@override def __repr__(self) -> str: return f"ParametricProportionalHazard({self.baseline!r}, {self.get_coefficients().tolist()!r})"
[docs] @final class ParametricAcceleratedFailureTime(ParametricLifetimeRegression): r""" Accelerated failure time regression. The cumulative hazard function :math:`H` is linked to the multiplier function :math:`g` by the relation: .. math:: H(t, x) = H_0\left(\dfrac{t}{g(\beta, x)}\right) = H_0(t e^{- \beta \cdot x}) where :math:`x` is a vector of covariates, :math:`\beta` is the coefficient vector of the effect of covariates, :math:`H_0` is the baseline cumulative hazard function [1]_. | Parameters ---------- baseline : FittableParametricLifetimeModel Any lifetime model that can be fitted. coefficients : tuple of floats (values can be None), default is (None,) Coefficients values of the covariate effects. Attributes ---------- baseline : FittableParametricLifetimeModel The regression baseline model (lifetime model). covar_effect : _CovarEffect The regression covariate effect. fitting_results : FittingResults, default is None An object containing fitting results (AIC, BIC, etc.). If the model is not fitted, the value is None. References ---------- .. [1] Kalbfleisch, J. D., & Prentice, R. L. (2011). The statistical analysis of failure time data. John Wiley & Sons. See Also -------- regression.ProportionalHazard : proportional hazard regression """
[docs] @override @document_args( base_cls=ParametricLifetimeRegression, args_docstring=_covar_docstring ) def hf( self, time: CoercibleFloat64_ND, *covar: CoercibleFloat64_ND, ) -> Float64_ND: t0 = time / self.covar_effect.g(*covar) return self.baseline.hf(t0) / self.covar_effect.g(*covar)
[docs] @override @document_args( base_cls=ParametricLifetimeRegression, args_docstring=_covar_docstring ) def chf( self, time: CoercibleFloat64_ND, *covar: CoercibleFloat64_ND, ) -> Float64_ND: t0 = time / self.covar_effect.g(*covar) return self.baseline.chf(t0)
[docs] @override @document_args( base_cls=ParametricLifetimeRegression, args_docstring=_covar_docstring ) def ichf( self, cumulative_hazard_rate: CoercibleFloat64_ND, *covar: CoercibleFloat64_ND, ) -> Float64_ND: return self.covar_effect.g(*covar) * self.baseline.ichf(cumulative_hazard_rate)
[docs] @override @document_args( base_cls=FittableParametricLifetimeModel, args_docstring=_covar_docstring ) def dhf( self, time: CoercibleFloat64_ND, *covar: CoercibleFloat64_ND, ) -> onp.ArrayND[np.float64]: t0 = time / self.covar_effect.g(*covar) return self.baseline.dhf(t0) / self.covar_effect.g(*covar) ** 2
[docs] @override @document_args( base_cls=FittableParametricLifetimeModel, args_docstring=_covar_docstring ) def jac_hf( self, time: CoercibleFloat64_ND, *covar: CoercibleFloat64_ND, ) -> onp.ArrayND[np.float64]: ndtime, *ndcovar = np.broadcast_arrays(time, *covar) g = self.covar_effect.g(*ndcovar) jac_g = self.covar_effect.jac_g(*ndcovar) # (nb_coef, ...) t0 = ndtime / g baseline_jac_hf_t0 = self.baseline.jac_hf(t0) # (p, ...) baseline_hf_t0 = self.baseline.hf(t0) baseline_dhf_t0 = self.baseline.dhf(t0) return np.concatenate( ( -jac_g / g**2 * ( baseline_hf_t0 + t0 * baseline_dhf_t0 ), # (nb_coef, ...) necessary to concatenate baseline_jac_hf_t0 / g, # (p, ...) ), axis=0, ) # (p + nb_coef, ...)
[docs] @override @document_args( base_cls=FittableParametricLifetimeModel, args_docstring=_covar_docstring ) def jac_chf( self, time: CoercibleFloat64_ND, *covar: CoercibleFloat64_ND, ) -> onp.ArrayND[np.float64]: ndtime, *ndcovar = np.broadcast_arrays(time, *covar) g = self.covar_effect.g(*ndcovar) jac_g = self.covar_effect.jac_g(*ndcovar) # (nb_coef, ...) t0 = ndtime / g baseline_jac_chf_t0 = self.baseline.jac_chf(t0) # (p, ...) baseline_hf_t0 = self.baseline.hf(t0) return np.concatenate( ( -jac_g / g * t0 * baseline_hf_t0, #  (nb_coef, ...) baseline_jac_chf_t0, # (p, ...) ), axis=0, ) # (p + nb_coef, ...)
@override def __repr__(self) -> str: return f"ParametricAcceleratedFailureTime({self.baseline!r}, {self.get_coefficients().tolist()!r})"