Competing Risks SurPyval Modelling

This page shows how to use SurPyval’s competing risks classes. For the theoretical background see Competing Risks Analysis.

Note

Every example on this page is executed when the documentation is built, so the outputs shown are produced by the installed version of surpyval.

Standard imports used throughout this page:

import surpyval as surv
import numpy as np
from matplotlib import pyplot as plt

Fitting a Competing Risks Model

The CompetingRisks class estimates a non-parametric cumulative incidence function (CIF) for each failure cause, using the Nelson-Aalen or Kaplan-Meier estimate of the overall survival function. Pass the observed times, a cause indicator, and optional censoring flags:

from surpyval.univariate.competing_risks import CompetingRisks
from surpyval import Weibull, Exponential

# Simulated data: two competing causes
np.random.seed(42)
t1 = Weibull.random(100, 50, 2.5)   # cause 1 latent times
t2 = Exponential.random(100, 1./80)             # cause 2 latent times

# Observed time is the minimum; cause is which latent time was smaller
x = np.minimum(t1, t2)
cause = (t2 < t1).astype(int)  # 0 = cause 1, 1 = cause 2
c = np.zeros_like(x, dtype=int)  # all observed (no censoring)

model = CompetingRisks.fit(
    x=x,
    c=c,
    e=cause,
)
print(model)
Competing Risk model with events:
[0, 1]

Once fitted, you can query the CIF for each cause:

t_plot = np.linspace(0, 150, 300)
for k, label in enumerate(['Cause 1 (Weibull)', 'Cause 2 (Exponential)']):
    plt.plot(t_plot, model.cif(t_plot, event=k), label=label)
plt.xlabel('Time')
plt.ylabel('Cumulative Incidence')
plt.legend()
plt.title('Competing Risks CIF by Cause')
Text(0.5, 1.0, 'Competing Risks CIF by Cause')
_images/Competing%20Risks%20SurPyval%20Modelling_2_1.png

Comparing incidence across groups: Gray’s test

Having estimated a cumulative incidence function for each group, surpyval.gray_test tests whether the CIFs differ for a chosen cause. It is the competing-risks analogue of the log-rank test, but with an important distinction: where a cause-specific log-rank compares the instantaneous hazards of a cause, Gray’s test compares the incidence — the CIFs directly. It does this by keeping competing-cause failures in the subdistribution risk set with an inverse-probability-of-censoring weight, rather than removing them. Reach for it when the clinical or engineering question is “how many fail of this cause”, not “how fast”.

Pass the observed times x, the per-observation cause label e, the group label, and the cause of interest (use c for censored rows). Here two groups have genuinely different cause-1 incidence:

from surpyval import gray_test

rng = np.random.default_rng(7)

def simulate(n, p_cause1):
    is1 = rng.random(n) < p_cause1
    t = rng.exponential(6.0, n)
    return t, np.where(is1, 1, 2)         # causes labelled 1 and 2

x_a, e_a = simulate(300, 0.35)
x_b, e_b = simulate(300, 0.60)            # higher cause-1 incidence
x = np.concatenate([x_a, x_b])
e = np.concatenate([e_a, e_b])
group = np.array([0] * 300 + [1] * 300)

result = gray_test(x, e, group, cause=1)
print('statistic = %.2f   df = %d   p = %.3g'
      % (result.statistic, result.df, result.p_value))
statistic = 48.86   df = 1   p = 2.75e-12

The tiny p-value correctly flags the difference in cause-1 incidence. On data where the two groups share the same incidence the test is calibrated, returning p-values spread over [0, 1] — including under heavy censoring, which is where the inverse-probability weighting earns its keep.

Fine-Gray Sub-distribution Hazards

The Fine-Gray model estimates the effect of covariates directly on the cumulative incidence function of a chosen cause, using an inverse-probability-of-censoring-weighted subdistribution risk set. e is the per-observation cause label (None for a censored row) and cause selects the cause of interest.

Here we simulate two-cause data whose cause-1 incidence follows a Fine-Gray model with coefficients \((0.7, -0.4)\), apply right-censoring, and recover the coefficients:

from surpyval.univariate.competing_risks import FineGray

rng = np.random.default_rng(1)
N, beta, p = 800, np.array([0.7, -0.4]), 0.5
Z = rng.uniform(-1, 1, size=(N, 2))
phi = np.exp(Z @ beta)
p1 = 1 - (1 - p) ** phi                    # P(cause = 1 | Z)
is1 = rng.uniform(size=N) < p1

x = np.empty(N)
e = np.empty(N, dtype=object)
v = rng.uniform(size=N)
w = 1 - (1 - v * p1) ** (1 / phi)          # invert the cause-1 CIF
x[is1] = (-np.log(np.clip(1 - w / p, 1e-12, 1.0)))[is1]
e[is1] = 1
x[~is1] = rng.exponential(1.0, size=N)[~is1]   # cause 2 mops up the rest
e[~is1] = 2

cens = rng.exponential(3.0, size=N)        # independent right-censoring
c = (x > cens).astype(int)
x = np.minimum(x, cens)
e[c == 1] = None

model = FineGray.fit(x, Z, e, c=c, cause=1)
model
Fine-Gray Subdistribution Hazard Model
======================================
Cause of interest   : 1
Coefficients (beta'Z acts on the subdistribution hazard):
   beta_0  :   0.770136  (se 0.104097, p 0.0000)
   beta_1  :  -0.416207  (se 0.096274, p 0.0000)

The IPCW correction is what lets the coefficients come back near their true values under censoring — a naive unweighted subdistribution risk set would be biased. Because the model targets the incidence directly, cif reads off the cumulative incidence of the cause at any covariate value:

t = np.linspace(0, 3, 200)
for z1, label in [(-1.0, 'Z1 = -1'), (1.0, 'Z1 = +1')]:
    plt.plot(t, model.cif(t, Z=[z1, 0.0]), label=label)
plt.legend()
plt.xlabel('Time')
plt.ylabel('Cumulative incidence of cause 1')
Text(0, 0.5, 'Cumulative incidence of cause 1')
_images/Competing%20Risks%20SurPyval%20Modelling_5_1.png

A positive \(\beta_0\) raises the cause-1 incidence, so the Z1 = +1 curve sits above Z1 = -1.

Cause-Specific Proportional Hazards

CompetingRisksProportionalHazards fits a proportional-hazards model per cause. With how="Cox" (the default) each cause is a Cox model with the other causes treated as censored; how="Fine-Gray" fits the subdistribution model above for every cause. It reuses the simulated data from the previous section:

from surpyval.univariate.competing_risks import (
    CompetingRisksProportionalHazards,
)

csph = CompetingRisksProportionalHazards.fit(x, Z, e, c=c, how="Cox")
# cumulative incidence of cause 1 at a covariate vector
csph.cif(np.array([0.5, 1.0, 2.0]), Z=[0.5, -0.5], event=1)
array([0.32634361, 0.49461867, 0.65304082])

The cause-specific model answers “what drives the rate of this cause among those still at risk”, while Fine-Gray answers “what drives the eventual incidence of this cause”; the two coincide only when the competing causes are unaffected by the covariates.