A White-Box DeepSurv, in JAX/Flax NNX

· 10 min read

#survival-analysis#deepsurv#cox#kernels#interpretability#yat#healthcare-ml#jax#flax#nnx#implementation

Part 8 of 12The Prototype Network
  1. 1What a Finite Kernel Buys an MLP
  2. 2Your Neuron Is a Direction. It Should Be a Picture.
  3. 3Your Network Is a List of Pictures. You Can Edit It.
  4. 4You Only Have to Train the Features
  5. 5You Don't Even Have to Train the Features
  6. 6How Far Down Can You Build?
  7. 7When 80% Should Mean 80%
  8. 8A Risk Model That Names Its Reasonsthis post's explainer
  9. 9The White-Box Survival Model on Trial
  10. 10Your Network Is a Stack of Layers. It Could Be a Fixed Point.
  11. 11Edit One Operator, Edit Every Depth
  12. 12One Kernel, Fitted Twice
Explainer companionA Risk Model That Names Its ReasonsWant the full intuition first? This is the runnable companion to the explainer.Read the explainer

The explainer built a survival model whose risk score decomposes into the prototype patients it resembles, and matched a standard DeepSurv on the concordance index while doing it. This is the implementation: the Cox partial-likelihood loss, both models in Flax NNX, the concordance evaluation, and the interpretability and editing code, each a short block. Every number below is from a real run; the script is scripts/yat_deepsurv.py, on the METABRIC breast-cancer cohort, and this is a research illustration, not a clinical tool.

The loss is the whole model

A DeepSurv is defined by its loss, not its architecture: any network that outputs one scalar log-risk per patient can be trained by the Cox partial likelihood, and the rest is a choice of trunk. So the loss comes first. At each observed death, the model should have scored the patient who died above everyone still at risk at that moment. Sort patients by descending time and the risk set of an event becomes a running prefix, so the log of the risk-set sum is a cumulative logsumexp from the top.

import jax, jax.numpy as jnp

def cox_ph_loss(logrisk, times, events):
    """Negative Cox partial log-likelihood (Breslow ties)."""
    order = jnp.argsort(-times)                 # descending time
    h = logrisk[order]; ev = events[order]
    m = jnp.max(h)                              # stabilize the logsumexp
    log_cum = m + jnp.log(jnp.cumsum(jnp.exp(h - m)) + 1e-12)  # log Σ_{risk set} e^h
    return -jnp.sum((h - log_cum) * ev) / (jnp.sum(ev) + 1e-8)

Only observed events (ev == 1) contribute a term, but censored patients are not discarded: they sit inside the risk-set sums of every death that preceded their censoring time. That is how the partial likelihood uses a patient you only watched for a while without inventing a death date for them.

Two trunks, one output

With the loss fixed, the two models differ by one attribute. The standard DeepSurv is a ReLU MLP; the Yat DeepSurv is a bank of prototype kernels. Both map covariates to one log-risk.

from flax import nnx

class YatLayer(nnx.Module):
    def __init__(self, d_in, k, *, rngs, Winit, b0=1.0, eps0=1.0):
        self.W = nnx.Param(jnp.asarray(Winit))                 # [K, d] prototype patients
        self.log_b = nnx.Param(jnp.full((), jnp.log(jnp.expm1(b0))))
        self.log_eps = nnx.Param(jnp.full((), jnp.log(jnp.expm1(eps0))))

    def __call__(self, x):
        b, eps = jax.nn.softplus(self.log_b.value), jax.nn.softplus(self.log_eps.value)
        dot = x @ self.W.value.T
        d2 = jnp.sum(x**2, -1, keepdims=True) + jnp.sum(self.W.value**2, -1) - 2 * dot
        return (dot + b)**2 / (d2 + eps)                       # [n, K] kernel activations

class YatSurv(nnx.Module):
    """Yat DeepSurv: prototype-kernel trunk -> scalar log-risk h(x) = Σ a_u φ_u(x)."""
    def __init__(self, d_in, k, *, rngs, Winit):
        self.yat = YatLayer(d_in, k, rngs=rngs, Winit=Winit)
        self.read = nnx.Linear(k, 1, use_bias=False, rngs=rngs)  # the readout weights a_u

    def features(self, x): return self.yat(x)
    def __call__(self, x):  return self.read(self.yat(x))[:, 0]

class MLPSurv(nnx.Module):
    """Standard DeepSurv: ReLU MLP -> scalar log-risk."""
    def __init__(self, d_in, k, *, rngs):
        self.l1 = nnx.Linear(d_in, k, rngs=rngs)
        self.l2 = nnx.Linear(k, k, rngs=rngs)
        self.out = nnx.Linear(k, 1, use_bias=False, rngs=rngs)
    def __call__(self, x):
        h = jax.nn.relu(self.l2(jax.nn.relu(self.l1(x))))
        return self.out(h)[:, 0]

The Yat trunk’s prototypes are seeded from k-means centroids on the training patients, so the model starts legible: each unit is already a plausible patient before a single gradient step. Training is full-batch, because the risk set is global (every patient’s loss term looks at every later patient), which makes full-batch the natural and standard choice at this data size.

import optax

@nnx.jit
def train_step(model, opt, xb, tb, eb):
    def loss_fn(m): return cox_ph_loss(m(xb), tb, eb)
    loss, grads = nnx.value_and_grad(loss_fn)(model)
    opt.update(model, grads)
    return loss

def train(model, X, t, e, epochs=400, lr=3e-3, wd=1e-4):
    opt = nnx.Optimizer(model, optax.adamw(lr, weight_decay=wd), wrt=nnx.Param)
    Xj, tj, ej = jnp.asarray(X), jnp.asarray(t), jnp.asarray(e)
    for _ in range(epochs):
        train_step(model, opt, Xj, tj, ej)
    return model

The stratification below is what those 400 epochs buy: split the held-out patients into risk tertiles by the model’s log-risk, and draw each tertile’s Kaplan-Meier curve as training proceeds. Both models pull the three curves apart; the Yat model’s separation is at least as clean.

Kaplan-Meier survival curves of low, mid and high risk tertiles separating over training epochs, for the Yat DeepSurv and the standard DeepSurv side by side
Risk stratification forming over training. Each held-out patient is placed in a risk tertile by the model’s log-risk, and the tertile’s real Kaplan-Meier curve is drawn each logged epoch. Both models separate the tertiles; the Yat trunk (left) is at least as clean as the ReLU trunk (right). Real curves from scripts/yat_deepsurv.py.

As the prototypes train, they migrate off their k-means seeds and settle onto the structure the partial likelihood cares about. Because a prototype is a point in patient space, that migration is literally visible: project the covariates to two dimensions and watch the prototype rings drift across the patient cloud.

Prototype rings drifting across a 2D PCA cloud of patients over training epochs, colored by whether they raise or lower risk
The prototype cloud settling onto patient data, in a 2D PCA of the covariate space. Grey dots are patients; orange rings are risk-raising prototypes, blue rings protective. The rings are the real W parameters at each logged epoch, no synthetic path. From scripts/yat_deepsurv.py.

Concordance: does it rank?

The scoreboard for a survival model is Harrell’s concordance index: over all pairs of patients whose ordering is known (one died before the other was censored), the fraction the model ranks correctly. Chance is 0.5. We use scikit-survival’s estimator so the censoring bookkeeping is standard.

from sksurv.metrics import concordance_index_censored

def cindex(model, X, t, e):
    risk = np.asarray(model(jnp.asarray(X)))          # higher = earlier event
    return concordance_index_censored(e.astype(bool), t, risk)[0]

Trained at matched capacity (24 hidden units) across three seeds, the two trunks tie on discrimination and the Yat trunk edges ahead on the probabilistic scores:

metricYat DeepSurvstandard DeepSurv
C-index0.627 ± 0.0330.623 ± 0.011
time-dependent AUC0.653 ± 0.0490.652 ± 0.016
integrated Brier (lower better)0.199 ± 0.0140.225 ± 0.008
Cox partial NLL (lower better)5.64 ± 0.196.84 ± 0.40

Attribution is a subtraction

The point of the Yat trunk is that its log-risk has parts. Because h(x) = read(yat(x)) is linear in the kernel activations, the exact per-prototype contribution is an element-wise product, no gradients, no approximation.

phi = np.asarray(model.yat(jnp.asarray(X)))           # [n, K] kernel activations
a   = np.asarray(model.read.kernel.value)[:, 0]       # [K] readout weights
contrib = phi * a[None, :]                             # [n, K] signed contributions
assert np.allclose(contrib.sum(1), np.asarray(model(jnp.asarray(X))), atol=1e-3)

The contributions sum back to the log-risk to within 2×1062\times10^{-6} on the held-out set, so a patient’s score is genuinely the sum of named prototype terms.

Signed contribution bars for a patient's top prototypes, orange raising risk and blue lowering it, with the terms summing to the patient's true log-risk
A patient’s log-risk decomposed into prototype contributions. Each bar is a real signed term a_u φ_u(x); orange raises risk, blue lowers it. The terms sum to the model’s true h(x) = 1.47. From scripts/yat_deepsurv.py.

Cohort deletion is a masked readout

To forget a cohort, find the prototype it is built around and zero that readout weight. The change to every patient is the exact closed form auϕu(x)-a_u\, \phi_u(x) for the deleted prototype uu, computable and auditable per patient.

assign = phi.argmax(1)                                # each patient's nearest prototype
# pick the prototype whose contribution mass is most concentrated on its own cohort
share = [np.abs(contrib[assign == u, u]).sum() / (np.abs(contrib[:, u]).sum() + 1e-9)
         for u in range(K)]
u = int(np.argmax([s if (assign == i).sum() >= 8 else -1 for i, s in enumerate(share)]))
cohort = assign == u                                  # the patients built around prototype u

a_edit = a.copy(); a_edit[u] = 0.0                    # zero that readout weight, no retraining
risk_before = (phi * a[None, :]).sum(1)
risk_after  = (phi * a_edit[None, :]).sum(1)
delta = risk_after - risk_before                      # = -a[u] * phi[:, u], exact per patient

On dense clinical covariates the deletion is not local the way it was on sparse images: every patient activates every prototype a little, so everyone moves by some exact amount. But the movement concentrates where the kernel says it should. Deleting prototype #12 (69% of its contribution mass on its own 28-patient cohort), the cohort shifts by 1.02 in log-risk on average; everyone else by 0.024, with 89% moving less than 0.05. The change to any patient is exactly their own resemblance to #12, a closed form you can show a regulator.

A 2D map of patients recoloring by risk as a cohort's prototype is deleted, the cohort ringed in black shifting while the rest barely moves
Cohort deletion in the 2D feature map. Patients are colored by log-risk; the cohort is ringed in black. As the prototype is removed, the cohort re-routes while the rest of the field barely moves. Every risk value is the real closed-form edit from scripts/yat_deepsurv.py.

Abstention is a max

A bounded kernel gives an out-of-distribution score for free: the largest activation a patient produces is a resemblance meter. Strangers score low.

kmax = phi.max(1)                                     # kernel-max resemblance per patient
# real patients: median 7.1; synthetic out-of-distribution patients: median 3.7

Walk a real patient off the data manifold and watch the meter fall into the region where the model should decline to answer.

A vertical marker for one patient sweeping left across two overlaid histograms of kernel-max scores for real and out-of-distribution patients as the patient is pushed off distribution
A patient walked off the data manifold. The histograms are the real kernel-max scores of in-distribution (blue) and out-of-distribution (grey) patients; the orange marker is the moving patient, its score computed live from the real kernel. From scripts/yat_deepsurv.py.

Calibration comes along

Because the Yat trunk’s log-risks are read off bounded kernels, its predicted survival tracks the observed survival closely, group by group. Bin the held-out patients into risk tertiles, and for each tertile compare the model’s mean predicted survival against the Kaplan-Meier estimate at a set of horizons: on the diagonal is honest.

Predicted versus observed survival for three risk tertiles at a set of follow-up horizons, for the Yat DeepSurv and the standard DeepSurv side by side
Predicted vs observed survival. Each point is a risk tertile at a follow-up horizon; the diagonal is a perfectly calibrated model. The Yat DeepSurv (left) hugs the diagonal across all three tertiles; the standard DeepSurv (right) drifts off it. Real predicted-vs-Kaplan-Meier points from scripts/yat_deepsurv.py.

That is the whole white-box DeepSurv: one loss, two trunks, and a handful of array operations that turn the legible trunk’s one number into reasons, an edit, and an abstention. The explainer is where the clinical story lives.

Cite as

Bouhsine, T. (). A White-Box DeepSurv, in JAX/Flax NNX. Records of the !mmortal Data Scientist. https://tahabouhsine.com/blog/a-risk-model-that-names-its-reasons-jax-flax-nnx/

BibTeX
@misc{bouhsine2026ariskmodelthatnamesitsreasonsjaxflaxnnx,
  author       = {Bouhsine, Taha},
  title        = {A White-Box DeepSurv, in JAX/Flax NNX},
  year         = {2026},
  month        = {jul},
  howpublished = {\url{https://tahabouhsine.com/blog/a-risk-model-that-names-its-reasons-jax-flax-nnx/}},
  note         = {Blog post, Records of the !mmortal Data Scientist}
}

References

  1. Katzman, J. L., Shaham, U., Cloninger, A., Bates, J., Jiang, T., Kluger, Y. (2018). DeepSurv: Personalized Treatment Recommender System Using a Cox Proportional Hazards Deep Neural Network. BMC Medical Research Methodology 18(1), 24.arXiv:1606.00931
  2. Cox, D. R. (1972). Regression Models and Life-Tables. Journal of the Royal Statistical Society, Series B 34(2), 187–220.
  3. Harrell, F. E., Califf, R. M., Pryor, D. B., Lee, K. L., Rosati, R. A. (1982). Evaluating the Yield of Medical Tests. JAMA 247(18), 2543–2546.
  4. Curtis, C., et al. (2012). The Genomic and Transcriptomic Architecture of 2,000 Breast Tumours (METABRIC). Nature 486, 346–352.
  5. Bouhsine, T. (2026). A Universal Reproducing Kernel Hilbert Space from Polynomial Alignment and IMQ Distance. arXiv:2605.03262