from collections.abc import Sequence
from typing import Any, Literal, NamedTuple, final
from typing_extensions import override
import numpy as np
import optype.numpy as onp
from ._parametric_regressions import LinearCovarEffect
from assetlife.base import FitConfig, FittingResults, MaximumLikelihoodOptimizer
from assetlife.typing import CoercibleFloat64_1D
class CoxData:
time: onp.Array[tuple[int, Literal[1]], np.float64]
covar: tuple[onp.Array[tuple[int, Literal[1]], np.float64], ...]
event: onp.Array[tuple[int, Literal[1]], np.bool_] | None
entry: onp.Array[tuple[int, Literal[1]], np.float64] | None
ordered_event_time: onp.Array1D[np.float64]
event_count: onp.Array1D[np.int64]
risk_set: onp.Array2D[np.bool_]
death_set: onp.Array2D[np.bool_]
ordered_event_covar: tuple[onp.Array[tuple[int, Literal[1]], np.float64], ...]
def __init__(
self,
time: onp.Array1D[np.float64],
covar: Sequence[onp.Array1D[np.float64]],
event: onp.Array1D[np.bool_] | None = None,
entry: onp.Array1D[np.float64] | None = None,
) -> None:
self.time = time[:, None]
self.event = (
event[:, None]
if event is not None
else np.ones_like(self.time, dtype=np.bool_)
)
self.entry = (
entry[:, None]
if entry is not None
else np.zeros_like(self.time, dtype=np.float64)
)
self.covar = tuple(c[:, None] for c in covar)
sizes = [len(x) for x in (self.time, self.event, self.entry, *self.covar)]
if len(set(sizes)) != 1:
raise ValueError(
f""""
All lifetime data must have the same number of values. Fields
length are different. Got {tuple(sizes)}.
"""
)
(
self.ordered_event_time, # uncensored sorted untied times
ordered_event_index,
self.event_count,
) = np.unique(
self.time[self.event == 1],
return_index=True,
return_counts=True,
)
# here risk_set is mask array on time
# left truncated & right censored
self.risk_set = np.logical_and(
(
np.vstack([self.entry[:, 0]] * len(self.ordered_event_time))
< np.hstack([self.ordered_event_time[:, None]] * len(self.time))
),
(
np.hstack([self.ordered_event_time[:, None]] * len(self.time))
<= np.vstack([self.time[:, 0]] * len(self.ordered_event_time))
),
)
self.death_set = np.vstack(
[self.time[:, 0] * self.event[:, 0]] * len(self.ordered_event_time)
) == np.hstack([self.ordered_event_time[:, None]] * len(self.time))
self.ordered_event_covar = tuple(
c[self.event[:, 0] == 1][ordered_event_index] for c in self.covar
)
class CoxPartialLifetimeLikelihood(
MaximumLikelihoodOptimizer[LinearCovarEffect, CoxData]
):
model: LinearCovarEffect
data: CoxData
config: FitConfig
def __init__(
self,
model: LinearCovarEffect,
data: CoxData,
config: FitConfig,
):
self.model = model
self.data = data
self.config = config
if "jac" not in self.config.scipy_minimize_options:
self.config.scipy_minimize_options["jac"] = self.jac_negative_log
if "hess" not in self.config.scipy_minimize_options:
self.config.scipy_minimize_options["hess"] = self.hess_negative_log
@property
@override
def nb_observations(self) -> int:
return len(self.data.time)
@override
def negative_log(self, params: onp.Array1D[np.float64]) -> float:
self.model.set_params(params)
return -(
np.log(self.model.g(*self.data.ordered_event_covar)).sum()
- np.log(self.psi()).sum()
)
def jac_negative_log(
self, params: onp.Array1D[np.float64]
) -> onp.Array1D[np.float64]:
self.model.set_params(params) # changes model params
return -(
np.column_stack(self.data.ordered_event_covar).sum(axis=0)
- (self.psi(order=1) / self.psi()).sum(axis=0)
)
def hess_negative_log(
self, params: onp.Array1D[np.float64]
) -> onp.Array2D[np.float64]:
self.model.set_params(params) # changes model params
psi_order_0 = self.psi()
psi_order_1 = self.psi(order=1)
hessian_part_1 = self.psi(order=2) / psi_order_0[:, :, None]
# print("hessian_part_1 [d, p, p]:", hessian_part_1.shape)
hessian_part_2 = (psi_order_1 / psi_order_0)[:, None] * (
psi_order_1 / psi_order_0
)[:, :, None]
# print("hessian_part_2 [d, p, p]:", hessian_part_2.shape)
return hessian_part_1.sum(axis=0) - hessian_part_2.sum(axis=0)
def psi(
self,
on: Literal["risk", "death"] = "risk",
order: Literal[0, 1, 2] = 0,
) -> onp.ArrayND[np.float64]:
"""Psi formula used for likelihood computations
Args:
on (str, optional): "risk" or "death". Defaults to "risk". If "death",
sum is applied on death set. order (int, optional): order derivatives
with respect to params. Defaults to 0.
Returns:
np.ndarray: psi formulation
If order 0, shape [m, 1]
If order 1, shape [m, p]
If order 2, shape [m, p, p]
"""
if on == "risk":
i_set = self.data.risk_set
elif on == "death":
i_set = self.data.death_set
if order == 0:
# shape [m]
return np.dot(i_set, self.model.g(*self.data.covar))
elif order == 1:
# shape [m, p]
return np.dot(
i_set,
np.column_stack(self.data.covar) * self.model.g(*self.data.covar),
)
elif order == 2:
# shape [m, p, p]
return np.tensordot(
i_set[:, :None],
np.column_stack(self.data.covar)[:, None]
* np.column_stack(self.data.covar)[:, :, None]
* np.asarray(self.model.g(*self.data.covar))[:, :, None],
axes=1,
).astype(np.float64)
@final
class BreslowPartialLifetimeLikelihood(CoxPartialLifetimeLikelihood):
s_j: onp.ArrayND[np.float64]
def __init__(
self,
model: LinearCovarEffect,
data: CoxData,
config: FitConfig,
):
super().__init__(model, data, config)
self.s_j = np.dot(self.data.death_set, np.column_stack(self.data.covar))
@override
def negative_log(self, params: onp.Array1D[np.float64]) -> float:
self.model.set_params(params) # changes model params
return -(
np.log(self.model.g(*np.unstack(self.s_j, axis=-1))).sum()
- (self.data.event_count[:, None] * np.log(self.psi())).sum()
)
@override
def jac_negative_log(
self, params: onp.Array1D[np.float64]
) -> onp.Array1D[np.float64]:
self.model.set_params(params) # changes model params
return -(
self.s_j.sum(axis=0)
- (self.data.event_count[:, None] * (self.psi(order=1) / self.psi())).sum(
axis=0
)
)
@override
def hess_negative_log(
self, params: onp.Array1D[np.float64]
) -> onp.Array2D[np.float64]:
self.model.set_params(params) # changes model params
psi_order_0 = self.psi()
psi_order_1 = self.psi(order=1)
hessian_part_1 = self.psi(order=2) / psi_order_0[:, :, None]
# print("hessian_part_1 [d, p, p]:", hessian_part_1.shape)
hessian_part_2 = (psi_order_1 / psi_order_0)[:, None] * (
psi_order_1 / psi_order_0
)[:, :, None]
# print("hessian_part_2 [d, p, p]:", hessian_part_2.shape)
return (self.data.event_count[:, None, None] * hessian_part_1).sum(axis=0) - (
self.data.event_count[:, None, None] * hessian_part_2
).sum(axis=0)
@final
class EfronPartialLifetimeLikelihood(CoxPartialLifetimeLikelihood):
s_j: onp.ArrayND[np.float64]
discount_rates: onp.ArrayND[np.float64]
discount_rates_mask: onp.ArrayND[np.bool_]
scipy_method = "trust-exact"
def __init__(
self,
model: LinearCovarEffect,
data: CoxData,
config: FitConfig,
):
super().__init__(model, data, config)
self.s_j = np.dot(self.data.death_set, np.column_stack(self.data.covar))
self.discount_rates = (
np.vstack(
[np.arange(np.max(self.data.event_count))] * len(self.data.event_count)
)
/ self.data.event_count[:, None]
)
self.discount_rates_mask = np.where(self.discount_rates < 1, True, False)
def _psi_efron(
self,
order: Literal[0, 1, 2] = 0,
) -> onp.ArrayND[np.float64]:
"""Psi formula for Efron method
Args:
order (int, optional): order derivatives with respect to params. Defaults to 0.
Returns:
np.ndarray: psi formulation for Efron method
If order 0, shape [m, max(d_j)]
If order 1, shape [m, max(d_j), p]
If order 2, shape [m, max(d_j), p, p]
"""
if order == 0:
# shape [m, max(d_j)]
return (
self.psi(order=order) * self.discount_rates_mask
- self.psi(on="death", order=order)
* self.discount_rates
* self.discount_rates_mask
)
elif order == 1:
# shape [m, max(d_j), p]
return (
self.psi(order=1)[:, None, :] * self.discount_rates_mask[:, :, None]
- self.psi(on="death", order=1)[:, None, :]
* (self.discount_rates * self.discount_rates_mask)[:, :, None]
)
elif order == 2:
# shape [m, max(d_j), p, p]
return (
self.psi(order=2)[:, None, :]
* self.discount_rates_mask[:, :, None, None]
- self.psi(on="death", order=2)[:, None, :]
* (self.discount_rates * self.discount_rates_mask)[:, :, None, None]
)
@override
def negative_log(self, params: onp.Array1D[np.float64]) -> float:
self.model.set_params(params) # changes model params
# .sum(axis=1, keepdims=True) --> sum on alpha to d_j
# .sum() --> sum on j
# using where in np.log allows to avoid 0. masked elements
m = self._psi_efron()
neg_L = -(
np.log(self.model.g(*np.unstack(self.s_j, axis=-1))).sum()
- np
.log(m, out=np.zeros_like(m), where=(m != 0))
.sum(axis=1, keepdims=True)
.sum()
)
return neg_L
@override
def jac_negative_log(
self, params: onp.Array1D[np.float64]
) -> onp.Array1D[np.float64]:
self.model.set_params(params) # changes model params
# .sum(axis=1) --> sum on alpha to d_j
# .sum(axis=0) --> sum on j
# using where in np.divide allows to avoid 0. masked elements
a = self._psi_efron(order=1)
b = self._psi_efron()[:, :, None]
return -(
self.s_j.sum(axis=0)
- np
.divide(a, b, out=np.zeros_like(a), where=(b != 0))
.sum(axis=1)
.sum(axis=0)
)
@override
def hess_negative_log(
self, params: onp.Array1D[np.float64]
) -> onp.Array2D[np.float64]:
self.model.set_params(params) # changes model params
psi_order_0 = self._psi_efron()
psi_order_1 = self._psi_efron(order=1)
# .sum(axis=1) --> sum on alpha to d_j
# using where in np.divide allows to avoid 0. masked elements
a = self._psi_efron(order=2)
b = psi_order_0[:, :, None, None]
hessian_part_1 = np.divide(a, b, out=np.zeros_like(a), where=(b != 0)).sum(
axis=1
)
# .sum(axis=1) --> sum on alpha to d_j
# using where in np.divide allows to avoid 0. masked elements
b = psi_order_0[:, :, None]
hessian_part_2 = (
np.divide(psi_order_1, b, out=np.zeros_like(psi_order_1), where=(b != 0))[
:, :, None, :
]
* (
np.divide(
psi_order_1, b, out=np.zeros_like(psi_order_1), where=(b != 0)
)
)[:, :, :, None]
)
hessian_part_2 = hessian_part_2.sum(axis=1)
return hessian_part_1.sum(axis=0) - hessian_part_2.sum(axis=0)
class CoxEstimation(NamedTuple):
timeline: onp.Array1D[np.float64]
values: onp.Array1D[np.float64]
se: onp.Array1D[np.float64] | None = None
[docs]
class SemiParametricProportionalHazard:
"""
Cox semi-parametric proportional hazard.
"""
fitting_results: FittingResults
_covar_effect: LinearCovarEffect
_likelihood: CoxPartialLifetimeLikelihood
_sf0_estimation: CoxEstimation
def __init__(
self,
time: onp.Array1D[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,
):
nb_covar = 1 if not isinstance(covar, Sequence) else len(covar)
self._covar_effect = LinearCovarEffect(*(0.0,) * nb_covar)
covar = (covar,) if not isinstance(covar, Sequence) else covar
self._likelihood = self.init_likelihood(time, covar, event, entry, **kwargs)
fitting_results = self._likelihood.optimize()
self._covar_effect.set_params(fitting_results.optimal_params)
self.fitting_results = fitting_results
timeline = self._likelihood.data.ordered_event_time.copy()
self._sf0_estimation = CoxEstimation(timeline=timeline, values=self._sf0())
def init_likelihood(
self,
time: onp.Array1D[np.float64],
covar: Sequence[onp.Array1D[np.float64]],
event: onp.Array1D[np.bool_] | None = None,
entry: onp.Array1D[np.float64] | None = None,
**kwargs: Any,
) -> CoxPartialLifetimeLikelihood:
covar_effect = LinearCovarEffect(*(0.0,) * len(covar))
x0 = kwargs.get("x0", np.random.random(len(covar)))
config = FitConfig(x0)
config.scipy_minimize_options["method"] = kwargs.get("method", "trust-exact")
config.covariance_method = kwargs.get("covariance_method", "exact")
cox_data = CoxData(time, covar, event=event, entry=entry)
_, event_count = np.unique(time[event == 1], return_counts=True)
if (event_count > 3).any(): # efron
return EfronPartialLifetimeLikelihood(self._covar_effect, cox_data, config)
if (event_count <= 3).all() and (2 in event_count):
return BreslowPartialLifetimeLikelihood(covar_effect, cox_data, config)
return CoxPartialLifetimeLikelihood(covar_effect, cox_data, config)
def get_params(self) -> onp.Array1D[np.float64]:
return self._covar_effect.get_params()
def _chf0(self) -> onp.ArrayND[np.float64]:
"""
The cumulative hazard function estimation
Parameters
----------
se : bool, default is False
If true, the estimated standard errors are returned too.
Returns
-------
tuple of 2 or 3 ndarrays
A tuple containing the timeline,
the estimated values and optionally the estimated standard errors (if se is set to true)
"""
return np.cumsum(
self._likelihood.data.event_count[:, None] / self._likelihood.psi()
)
# if se:
# var = np.cumsum(
# self._likelihood.data.event_count[:, None] / self._likelihood.psi() ** 2
# )
# conf_int_values = np.hstack(
# [
# values[:, None]
# + np.sqrt(var)[:, None] * norm.ppf(0.05 / 2, loc=0, scale=1),
# values[:, None]
# - np.sqrt(var)[:, None] * norm.ppf(0.05 / 2, loc=0, scale=1),
# ]
# )
# return values, conf_int_values
def _sf0(self) -> onp.ArrayND[np.float64]:
"""
The survival function estimation
Returns
-------
tuple of 2 or 3 ndarrays
A tuple containing the timeline,
the estimated values and optionally the estimated standard errors (if se is set to true)
"""
return np.exp(-self._chf0())
# if se:
# chf, chf_conf_int_values = self.chf0(se=True)
# return np.exp(-chf), np.exp(-chf_conf_int_values)
[docs]
def sf(self, *covar: CoercibleFloat64_1D, se: bool = True) -> CoxEstimation:
"""
The survival function estimations.
Parameters
----------
covar: np.array
array with covariates values
se : bool, default True
If True, the standard errors are returned in addition to timeline
and sf values.
Returns
-------
out : tuple of timeline, values, optionally se. Default is None
A timeline, corresponding sf values and optionnaly the standard
errors. If the estimations does not exist yet, returns None.
"""
g = self._covar_effect.g(*covar)
g = g[:, None] if g.ndim == 1 else g
if se and self.fitting_results.covariance_matrix is not None:
return CoxEstimation(
self._sf0_estimation.timeline,
self._sf0_estimation.values**g,
se=self._sf0_estimation.values**g
* np.sqrt(
self._q1_q2_sum(
*covar, covariance_matrix=self.fitting_results.covariance_matrix
)
),
)
return CoxEstimation(
self._sf0_estimation.timeline,
self._sf0_estimation.values**g,
)
def _q1_q2_sum(
self,
*covar: CoercibleFloat64_1D,
covariance_matrix: onp.ArrayND[np.float64],
) -> onp.ArrayND[np.float64]:
"""
Klein and Moeschberger: Survival Analysis Techniques for Censored and
Truncated Data (p. 284).
"""
psi_values = self._likelihood.psi()
psi_order_1 = self._likelihood.psi(order=1)
d_j_on_psi = self._likelihood.data.event_count[:, None] / psi_values
q3 = np.cumsum(
(
(psi_order_1 / psi_values)[None, :, :]
- np.column_stack(covar)[:, None, :]
)
* d_j_on_psi[None, :, :],
axis=1,
) # [m: new sample for inference, t: timeline, p]
q2 = np.squeeze(
np.matmul(
q3[:, :, None, :],
np.matmul(
covariance_matrix[None, None, :, :],
q3[:, :, :, None],
),
)
) # [m, t]
q1 = np.cumsum(d_j_on_psi * (1 / psi_values))
return q1 + q2