Evaluating Survival Predictions
Score censored survival models with concordance and IPCW Brier scores. Learn to compare Cox, Weibull AFT, and Kaplan-Meier forecasts honestly.
Machine Learning from ScratchAll chapters Evaluating Survival PredictionsFull chapter · Sections in this chapter1 / 10 ↓
Reading tools Definitions, links & notes
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
Evaluating Survival Predictions
We have spent the previous four chapters building survival models. We learned how censoring changes what a likelihood can see, how Kaplan–Meier and Nelson–Aalen estimate a marginal curve without covariates, how Cox proportional hazards turns covariates into hazard ratios, and how accelerated failure-time models describe the same data on the log-time scale. Once we have a fitted model, how do we know whether it is any good?
That question is harder than it looks in the survival setting, because the quantity we want to score is the thing censoring hides. Suppose we maintain a fleet of 900 industrial units. For each unit we record the hours it ran, a few operating covariates, and whether we saw it fail or merely stopped watching when the study ended. A model now returns two kinds of output. It may return a risk score, a single number per unit that orders units from "fails soonest" to "lasts longest". Or it may return a whole survival function, a curve giving the probability that unit survives past any horizon we care about. These are different products, and they support different decisions: a risk score tells a planner which units to inspect first, while survival probabilities can inform replacement planning over a two-year horizon. If each initial unit is replaced once on its first failure, summing the model's two-year failure probabilities estimates the expected replacement count. A replacement budget also needs unit costs and a policy for uncertainty.
Censoring blocks both evaluations. If a unit leaves the study at month 6, we cannot ask whether it survived past month 8, so we generally cannot determine its squared error for an ordinary mean-squared-error total. A prediction of one-half is the exception: its squared error is one-quarter for either outcome. If we simply discard it, we throw away the fact that it survived at least to month 6. If we pretend it survived past month 8, we have invented evidence. Manipulating censored durations is worse still: treating them as observed failures asserts that a unit died at the moment we stopped writing down its status. The metrics below use the observed event status and follow-up time without assigning an unobserved failure time.
Two censoring-aware tools carry most of the weight. The concordance index asks whether the model orders failure times correctly, scoring only the pairs of units for which the data alone determine who failed first. The time-dependent Brier score asks whether the model's predicted probabilities are close to the observed outcomes at a chosen horizon, reweighting the units we can fully observe so that they stand in for the units we cannot. We will derive both, discover exactly what each can and cannot see, and then apply them to a held-out equipment cohort where Kaplan–Meier, Cox, and Weibull AFT predictions are compared with censoring weights estimated only on the training partition. The third piece, calibration, is what closes the loop: a model can rank units perfectly and still be badly wrong about the numbers we plan with.
Advantages
Evaluation metrics that account for censoring let us compare models using the recorded follow-up. A health system triaging transplant candidates, a lender scoring loan prepayment, a maintenance team scheduling overhauls, and a reliability engineer forecasting warranty claims can all face the same observation problem: the outcome of interest is a time, and the observation window ends before every event is observed. The censoring model must be justified for the population scored. Concordance uses observed pairwise orderings. Inverse-probability-of-censoring weighting (IPCW) instead reweights observed contributions under assumptions about censoring and support. Their interpretation depends on those assumptions and on the evaluation horizon; three years of follow-up alone does not establish model quality.
The two families of metrics measure different things, and that separation is a practical advantage. Concordance depends only on the ranking induced by a risk score. It is invariant to any strictly increasing transformation of the score, so it works for Cox linear predictors, for negated AFT log-scale terms, for negated restricted mean survival times, and for the risk output of a random survival forest that never produces an interpretable linear form. The Brier score, by contrast, depends on the predicted probabilities themselves and measures their squared error. Reporting both separates ranking from probability accuracy. Brier error also depends on the outcome uncertainty and resolution, so it does not isolate calibration; the calibration plots below compare predicted and observed probabilities directly.
The Brier score uses an explicit horizon, and truncated IPCW concordance uses a cutoff for the earlier failure; Harrell's concordance here has no horizon parameter. These choices connect evaluation directly to decisions. The prediction we need at a 1,500-hour maintenance window is not the same object as the prediction we need for a five-year warranty reserve, and a horizon-indexed metric can be computed at exactly the horizon the decision uses. Report the scored cohort and evaluation window alongside each metric so that a reader can see which units and period the number describes.
Both metrics accept predictions from different model families. The dense concordance implementation below compares pairs, with quadratic time and memory in the evaluation sample size; the Brier calculation is a mean of per-subject contributions at each horizon. Estimating the censoring distribution adds a Kaplan–Meier fit. Scoring fixed predictions does not refit the survival model, while bootstrapping repeats the scoring calculation. Runtime therefore depends on sample size, horizon count, and the number of resamples.
Disadvantages
Concordance does not measure probability calibration. Two models with exactly the same ranking of units have exactly the same concordance index, even if one predicts a survival probability of 0.9 for a unit whose true survival probability is 0.2. A concordance of 0.75 therefore says nothing about whether the predicted probabilities can be used in a budget. Given the supplied scalar scores, it does not identify the full shape of the predicted curves. If those scores encode median ordering, errors in tail probabilities can remain invisible. A change to a curve can still change concordance when it changes the chosen score's ordering. If a decision needs probabilities, concordance is a screening tool, not the answer.
Harrell's concordance is also sensitive to the censoring distribution. The set of usable pairs is determined by who was censored when, so studies of the same population can target different comparable-pair mixes when follow-up differs. Cross-study comparisons therefore need their follow-up patterns and target populations stated. The IPCW-corrected version of concordance targets a censoring-independent estimand, but it buys that independence with a modelling assumption: the censoring model must match the required independence structure, and censoring survival must be positive over the specified target window. The pooled weights here assume censoring independent of failure time and prediction covariates.
IPCW metrics inherit the same fragility. Inverse weights are unstable by construction: if approaches zero, the weight grows without bound and a single observation can dominate the average. Positivity failures are not hypothetical. In the six-unit teaching cohort below, the last unit is censored at the final follow-up time, which drives the estimated censoring survival to exactly zero, so the strict Brier estimator used here rejects that horizon and later ones. A larger sample cannot remove a population support failure. Large inverse weights can increase variance, and the estimated Brier score can exceed one in small samples even though the population quantity is bounded in . The reason is that the IPCW estimator is an average of weighted squared errors rather than a convex combination: a single small inflates one weight, so one unit's weighted squared error can exceed one and push the average above the bound that holds for the unweighted population quantity.
A Brier score at one horizon reduces event timing to a binary outcome. At 1,500 hours, a unit that fails at 1,600 hours and one observed running through 3,600 hours both have the survivor outcome; their errors can still differ if their predicted probabilities differ. If timing across a window matters, evaluate several horizons or integrate over a specified interval. This does not remove the censoring assumptions. Withdrawal related to impending failure can make the pooled censoring probabilities used here inappropriate, potentially biasing the weighted scores; a larger sample does not correct a misspecified observation model.
Formula
Evaluation starts by fixing exactly what we observe, because censoring determines which comparisons are even possible. For units we observe a triple , where:
- is the true failure time of unit , unobserved when the unit is censored
- is the censoring time, the moment we stop observing the unit
- is the recorded follow-up time
- is the event indicator, equal to 1 when we saw the failure and 0 when the unit was censored
- is a vector of covariates
The pair is the part we see for every unit. The true failure time is only partly known: it is pinned down when and bounded below when .
Collecting the rows into a matrix and the follow-up times into a vector , an entire survival dataset is the pair plus . Evaluation also needs the model's risk scores or survival probability forecasts, the chosen horizons or cutoff, and a censoring model where IPCW is used. The observed outcomes alone do not determine a model's evaluation score.
Risk scores and survival probability predictions
A survival model can return two distinct products, and the difference between them is the key to choosing an evaluation metric. A risk score is a single number attached to unit , with the convention that larger means "fails sooner". A survival prediction is a function defined for every horizon . A risk score is a ranking device; a survival curve is a probability forecast.
The Cox proportional hazards model produces both. Its hazard specification is
where:
- : the hazard, the instantaneous failure rate at time for a unit with covariates
- : the unspecified baseline hazard, shared by all units
- : the covariate vector for a unit
- : the coefficient vector, one log hazard ratio per covariate
To turn a hazard into a survival function, we use the identity , where is the cumulative hazard (the integral of the hazard from to ). That identity holds because , so integrating both sides from 0 to and exponentiating recovers (using ). Applying it here, the covariate term factors out of the integral because it does not depend on time:
The linear predictor is the natural risk score for a Cox model, and in matrix form we write the whole vector of predictors at once as
where:
- : the vector of linear predictors, one per unit
- : the covariate matrix
- : the coefficient vector
- : the number of units
- : the number of covariates
For finite linear predictors, multiplies the same baseline hazard for every unit, so a larger gives a larger hazard multiplier. Its survival curve is no higher and, in exact arithmetic, is strictly lower wherever . Curves can coincide before the first baseline-hazard jump or at an endpoint where the cumulative hazard diverges and survival reaches zero. The linear predictor itself supplies a horizon-independent risk ordering. For crossing curves, a chosen horizon or another specified curve functional determines the ranking.
Accelerated failure-time models are specified on the log-time scale instead:
where:
- : the true failure time of a unit
- : the intercept on the log-time scale
- : the covariate vector for a unit
- : the coefficient vector on the log-time scale
- : a scale parameter
- : a standard Gumbel random variable for minima, independent of , with
Writing and , we can derive the survival function in a few steps. Starting from the definition and substituting,
The inner exponential simplifies because , using and . Hence
For an AFT model the linear predictor is a log-scale term, so a larger value means longer life and lower risk. The risk score must therefore be oriented in the opposite direction, for example , or equivalently . This sign convention is not cosmetic: reversing it turns a model with concordance above 0.5 into one with concordance below 0.5 (specifically, in the absence of ties), so a useful ranking is turned into a harmful one.
A risk score can be formed from a chosen functional of the survival curve, such as , a finite negative predicted median, or a negative restricted mean survival time. For a Cox model, preserves the linear-predictor risk ranking in exact arithmetic when ; lower survival means greater risk. Numerical underflow can collapse distinct survival predictions to zero, so use directly for Cox concordance when it is available. Step-function medians can introduce ties or remain undefined, so these choices need not give identical concordance in finite samples. For crossing curves, the chosen horizon can also change the ordering.
Concordance and comparable pairs
The concordance index asks a question about pairs of units: when the data tell us that unit failed before unit , does the model give the higher risk score? The whole subtlety is in the first clause, because censoring means the data often tell us nothing. The formula is therefore built around the idea of a comparable pair, a pair of units whose failure order is established by the observations themselves.
Consider a pair with . If , then exactly, and since , we can chain the inequalities to get , establishing . The ordering is therefore established by the data, and we call the pair comparable. Now suppose instead that . All we know is . It is entirely possible that lies between and , or beyond ; the data do not decide. The pair is not comparable and must be dropped. The same logic applies when and : then plays the role of the earlier, uncensored member and the pair is comparable with the roles reversed. Pairs where the earlier member is censored, and pairs where both members are censored, contribute nothing. The displayed rule deliberately excludes pairs with equal recorded times. Under our event-before-censoring convention, a failure and a censoring at the same recorded time can still reveal a strict failure order; that tied case is outside this rule.
Harrell's concordance index is a weighted fraction of comparable pairs that the model orders correctly, with a tie in the risk score counted as half credit:
where:
- : the estimated Harrell concordance index
- : the risk score for unit , with larger values meaning shorter predicted survival
- : the recorded follow-up time of unit
- : the event indicator for unit , equal to 1 when the failure was observed
- : the indicator function, equal to 1 when its argument is true and 0 otherwise
Reading the pieces: the outer sum runs over all ordered pairs; the indicator selects usable pairs and acts as a switch that zeroes out everything else; the two inner indicators give one full point for a correct ordering and half a point for a tie; and the denominator counts how many usable pairs there were, which turns the sum into an average. The numerator is credit, the denominator is opportunity, and Harrell's index is the fraction of available credit relative to opportunity that the model converts. The ratio is defined only when at least one comparable pair exists; otherwise its denominator is zero and the helper raises an error.
Without censoring, the estimator targets the population concordance with half credit for tied risk scores. Under censoring, Harrell's statistic instead targets the mix of comparable pairs selected by this study's follow-up pattern.
This target averages ranking credit over two independent units whose true failure times satisfy . Censored observations reveal only some such pairs, which is why Harrell's estimate can depend on follow-up.
When no observation is censored and failure times are distinct, every pair contributes to survival concordance. This is related to ROC AUC through time-dependent discrimination measures, but it is not generally the ordinary binary AUC at a fixed horizon. For example, times and risk scores give concordance and a binary AUC of at horizon .
Harrell's comparable-pair mix depends on censoring. Truncated IPCW concordance instead targets the ranking credit among true failure pairs with and . With continuous censoring, Uno's estimator uses inverse-square censoring weights. The formula below includes the tie-aware product needed under our event-before-censoring convention:
where:
- : truncated IPCW concordance, using Uno's inverse-square form when censoring is continuous
- : a fixed upper limit on the earlier failure time, inside positive censoring support
- : the censoring survival immediately before the failure time
- : the risk score for unit , with larger values meaning shorter predicted survival
- : the recorded follow-up time of unit
- : the event indicator for unit , equal to 1 when the failure was observed
- : the censoring survival estimated by reverse Kaplan–Meier; at a tied recorded time, original failures leave its risk set before the censoring jump
- : the censoring time, the moment observation of a unit stops
Choose a fixed truncation horizon with positive censoring survival throughout the evaluation window, and include only earlier failures with . For continuous censoring times, conditional on a qualifying latent pair with earlier failure time , the probability of observing that pair is , which explains the inverse-square weight. Under our convention , censoring ties require the more precise product : the failing unit needs , while the comparison unit needs . The pooled weights used here assume that is independent of the joint pair .
Censoring weights and time-dependent Brier scores
Concordance only needs a ranking, so it can ignore the actual probability values. The Brier score cannot. At horizon it measures squared error on the probability scale:
where:
- : the Brier score at horizon , the mean squared error of the survival probability forecast
- : the number of units in the evaluation sample
- : the realised outcome for unit , equal to 1 if unit survives past and 0 if it fails by then
- : the model's predicted probability that unit survives past
- : the true failure time of unit , which is only partly observed under censoring
is the realised outcome: 1 if unit is still running at , 0 if it has failed by then. With fully observed outcomes this is just the mean squared error of a probability forecast, and it is minimised in expectation by the true conditional survival probability. This makes the Brier score a proper probability score. Its aggregate value does not identify calibration alone, so we also compare observed and predicted probabilities within groups.
Censoring hides for part of the sample. Under our event-before-censoring convention, four cases describe what is known:
- If and , the unit failed by , so is known.
- If , the unit was observed past , so is known.
- If and , then , so is also known.
- If and , the unit was censored before the horizon, so is unknown.
The naive fixes are to drop those units, score them as survivors, or score their censoring times as failures. These operations make different unsupported assumptions about outcomes that were not observed.
Inverse probability of censoring weighting divides each observed outcome contribution by its observation probability. Under censoring independent of failure time and prediction covariates, with . The left limit matters when censoring and failure times can tie; with continuous censoring times it equals .
The in the denominator cancels the probability of observing a failure at , so each observed failure represents units. For the survivor term, we deliberately select units observed strictly past : . Under the stated independence assumptions and , even if censoring has an atom at . A unit censored exactly at has a known survivor outcome, but is excluded by this selection rule. An inclusive rule would instead select and divide by ; that alternative is not the estimator implemented below.
Applying both identities term by term gives the Graf estimator, the standard IPCW Brier score:
where:
- : the estimated Brier score at horizon
- : the number of units in the evaluation sample
- : the model's predicted survival probability for unit at horizon
- : the recorded follow-up time of unit
- : the event indicator for unit , equal to 1 when the failure was observed
- : the estimated survival function of the censoring distribution, evaluated at time
The first term collects observed failures by : their squared error is , with weight . The second term selects units observed strictly past : their squared error is , with weight . Units censored at or before contribute zero to this particular estimator, although censoring exactly at reveals a survivor outcome. With true censoring probabilities, positive support, and the stated independence assumptions, these selected weighted contributions recover the population Brier error in expectation. For a fixed probability forecast and independent, identically distributed evaluation units, an oracle law of large numbers makes the true-weight average converge to that expectation. One sufficient condition for estimated weights to reach the same target is uniform consistency of the required censoring probabilities, including their left limits, with the true probabilities bounded away from zero over the evaluation window. Uniform control keeps the reciprocals controlled; pointwise consistency alone does not guarantee this. For a forecast fitted on independent training data, condition on the fit and target that fitted forecast's population Brier error. The identity is not a promise that a finite sample uses every known outcome or has no information loss.
For this strict Brier estimator, require and at contributing failure times ; the implementation also checks their estimated counterparts. Very small values can make inference unstable. For general covariate-dependent losses, the pooled Kaplan–Meier censoring weights used here are justified under the sufficient condition that is independent of the joint pair . If failure and censoring are only conditionally independent given , use a correctly specified conditional censoring model in each unit's weights instead. A training-based censoring model also needs to describe the evaluation population. When the horizon is not fixed in advance, the integrated Brier score summarises the path:
where:
- : the estimated integrated Brier score over the full follow-up window
- : the largest horizon included in the integration, the upper end of the evaluation window
- : the estimated IPCW Brier score at horizon
- : the -th point of the integration grid, with ; for a window such as the implementation's , integrate over that window and divide by
- : the number of grid points
This is the trapezoid rule applied to the grid. Reading the right-hand side: each trapezoid's area is its average height times its width, and dividing the total by normalises the integral to the scale of a single Brier score. The factor in front plays the role of the in the integral definition , so the trapezoid approximation lands on the same scale.
Mathematical properties
- With finite nonnegative pair weights and a positive comparable-pair denominator, concordance lies in . Conditional on those pairs and weights, an independent random ranking with exchangeable score pairs gives in expectation, including when tied scores receive half credit. In exact arithmetic, a strictly increasing transformation on the score's domain preserves its pairwise rankings and ties. Examples include positive rescaling, exponentiation over the real numbers, and a logarithm when every score is positive; nonpositive rescaling or a transformation that merges distinct scores need not preserve concordance.
- When at least one comparable pair contributes positive weight, a constant risk score gives concordance because every contributing pair receives half credit. This includes a marginal Kaplan–Meier baseline. With no contributing pair, Harrell or truncated IPCW concordance is undefined rather than .
- Harrell's concordance depends on the censoring distribution through the set of comparable pairs. Uno's concordance targets a censoring-independent quantity but requires independent censoring and a positive censoring survival function.
- With the true censoring probabilities and the stated independence assumptions, the IPCW Brier score has the same expectation as the complete-outcome Brier score, minimised by the true conditional survival probability. Under the consistency and reciprocal-control conditions stated above, estimated weights recover this target asymptotically; they do not give an exact finite-sample unbiasedness guarantee.
- For a binary horizon outcome and probability forecast , let and . Here means survival past the horizon, so is the marginal survival probability. The expected Brier score is . The reliability term measures the gap between each forecast and the conditional probability at that forecast level; the resolution term measures how those conditional probabilities vary. The uncertainty term is the marginal outcome variance. It is not a lower bound on prediction error, because informative forecasts can offset it through resolution. Exact forecast levels, or groups with a constant forecast, give this identity; coarse bins with varying forecasts require additional terms. This probability-score decomposition does not turn concordance into a calibration measure: concordance uses pairwise ranking credit rather than squared probability errors.
- In finite samples the IPCW Brier estimator can fall outside , because a small can make a single term large; small censoring survival can increase its variance, with the effect depending on the forecasts and event distribution.
Visualizing Survival Prediction Evaluation
We start with a cohort small enough to inspect by hand. Six units, followed for up to twelve months, with a risk score and a predicted survival probability at an eight-month horizon attached to each. The pairwise and weighted-error examples use this table and can be checked line by line. A separate illustration uses two explicitly specified Weibull curves to show how a survival probability becomes a ranking score and a squared error.
ConsoleOut [4]Expand
unit time event risk pred_surv_at_8 A 3.0 1 0.9 0.55 B 5.0 1 0.6 0.62 C 6.0 0 0.7 0.70 D 8.0 1 0.3 0.75 E 9.0 0 0.4 0.80 F 12.0 0 0.1 0.85
Comparing pairs on a timeline
Each row of the cohort is a follow-up window that opens at time zero and closes either with a failure or with censoring. For the strictly unequal recorded times used by our comparable-pair rule, the pair is usable only if the earlier row closes with a failure: then its failure time is known to precede the other unit's true failure time. This six-unit table has no recorded-time ties.
VisualizationOut [6]Expand

The figure makes the asymmetry of censoring concrete. Unit A fails at month 3 with the highest risk score in the cohort, so every pair in which A is the earlier member is usable, and every one of those pairs is concordant. Unit C is censored at month 6 with a score of 0.70, which is higher than D's score of 0.30. If we mistakenly treated C as a failure at month 6, we would count the pair (C, D) as concordant and give the model unsupported credit. The data do not support that inference: C might have failed at month 7 or at month 20. The observed-pair concordance formula excludes this pair through the indicator .
The timeline follows individual units; @fig-pair-status shows all fifteen pair decisions together. Read either triangle of the symmetric matrix once: nine pairs earn concordance credit, two are discordant, and four have an unknown failure order. The two discordant pairs are B-C and D-E.
VisualizationOut [7]Expand

From a survival curve to a risk score and an error
A predicted survival curve plays two roles. Read vertically at a fixed horizon, its height orders units by risk. Read as a function, it supplies the probability that enters the Brier score. The next figure shows both readings on two illustrative Weibull curves with the same shape but different scales.
PythonIn [8]Expand
# Two Weibull survival curves with equal shape and different scales.
rho_ill = 1.6
lam_low, lam_high = 2200.0, 900.0
tau_ill = 1500.0
t_grid = np.linspace(0.0, 3600.0, 400)
surv_low = np.exp(-((t_grid / lam_low) ** rho_ill))
surv_high = np.exp(-((t_grid / lam_high) ** rho_ill))
s_high_tau = float(np.exp(-((tau_ill / lam_high) ** rho_ill)))
s_low_tau = float(np.exp(-((tau_ill / lam_low) ** rho_ill)))
score_failure_target = (tau_ill, s_high_tau / 2.0)
score_survivor_target = (tau_ill, (1.0 + s_high_tau) / 2.0)
score_failure_offset = (44, 28)
score_survivor_offset = (44, 28)
score_failure_segment = ([tau_ill, tau_ill], [0.0, s_high_tau])
score_survivor_segment = ([tau_ill, tau_ill], [s_high_tau, 1.0])VisualizationOut [9]Expand

The high-risk unit has and the low-risk unit has at hours, computed above into s_high_tau and s_low_tau, so the model already ranks them: the steep curve sits below the shallow one at every positive horizon; both equal one at time zero. That ranking is all concordance needs, and it is why concordance can be computed from a single risk score per unit.
ConsoleOut [10]Expand
high-risk S_hat(tau) = 0.104 low-risk S_hat(tau) = 0.582
The Brier score needs more. If the high-risk unit failed before 1,500 hours, the model's squared error is ; if it survived, the error is . The same prediction is excellent or terrible depending on an outcome we may never observe, which is exactly the situation IPCW weights are designed to handle.
How censoring weights reweight the Brier score
Now we compute the per-unit contributions at months using a naive survivor rule and IPCW. For these untied teaching data, swapping event and censoring indicators in Kaplan–Meier estimates . With failure/censoring ties and the convention for an observed failure, reverse Kaplan–Meier must remove original failures from the censoring risk set before the censoring jump at that time, and failure weights use the left limit .
PythonIn [11]Expand
# Reuse censoring_curve and step_value from the helper cell above.
tiny_times = tiny["time"].to_numpy(dtype=float)
tiny_event = tiny["event"].to_numpy(dtype=float)
tiny_risk = tiny["risk"].to_numpy(dtype=float)
tiny_pred = tiny["pred_surv_at_8"].to_numpy(dtype=float)
tiny_g_times, tiny_g = censoring_curve(tiny_times, tiny_event)
g_at_t = step_value(tiny_times, tiny_g_times, tiny_g)
tau_tiny = 8.0
g_tau = float(step_value(tau_tiny, tiny_g_times, tiny_g))
failed_by_tau = (tiny_times <= tau_tiny) & (tiny_event == 1)
alive_at_tau = tiny_times > tau_tiny
g_before_t = step_value(tiny_times, tiny_g_times, tiny_g, side="left")
assert g_tau > 0 and np.all(g_before_t[failed_by_tau] > 0)
contrib_ipcw = np.zeros_like(tiny_times)
contrib_ipcw[failed_by_tau] = (
tiny_pred[failed_by_tau] ** 2 / g_before_t[failed_by_tau]
)
contrib_ipcw[alive_at_tau] = (1.0 - tiny_pred[alive_at_tau]) ** 2 / g_tau
contrib_naive = np.where(failed_by_tau, tiny_pred**2, (1.0 - tiny_pred) ** 2)ConsoleOut [12]Expand
unit time event pred_surv_at_8 g_hat naive ipcw A 3.0 1 0.55 1.000 0.3025 0.3025 B 5.0 1 0.62 1.000 0.3844 0.3844 C 6.0 0 0.70 0.750 0.0900 0.0000 D 8.0 1 0.75 0.750 0.5625 0.7500 E 9.0 0 0.80 0.375 0.0400 0.0533 F 12.0 0 0.85 0.000 0.0225 0.0300 G_hat(tau = 8) = 0.750 naive Brier at tau = 0.2337 IPCW Brier at tau = 0.2534
VisualizationOut [14]Expand

Unit C, censored at month 6, receives a naive contribution of because the naive rule assumes it survived past month 8. The model had predicted a comfortable 0.70 for a unit whose fate we never learn, and the naive score rewards it. Under IPCW, C contributes exactly nothing, and the units that carry information are upweighted: D's contribution rises from to because , and E and F rise by the same factor. Here the IPCW score is about 8.4 percent larger than the survivor-imputed score. The size and direction of the difference depend on the predictions and the censoring pattern.
Example
We now work the same six units through the arithmetic by hand. The purpose is not to replace the code but to make every number in the code auditable. Once the formulas have been evaluated on a table this small, the vectorised implementation has a reference point we can trust.
ConsoleOut [15]Expand
unit time event risk pred_surv_at_8 A 3.0 1 0.9 0.55 B 5.0 1 0.6 0.62 C 6.0 0 0.7 0.70 D 8.0 1 0.3 0.75 E 9.0 0 0.4 0.80 F 12.0 0 0.1 0.85
Working through concordance by hand
List the units with their observed time, event indicator, and risk score: A, B, C, D, E, F. There are unordered pairs, and we classify each one by asking whether the earlier member is an observed failure.
Begin with A. A failed at month 3, so every pair in which A is the earlier member is usable: A with B, C, D, E, and F. That is five pairs, and in every one of them exceeds the other score. Five concordant pairs.
B fails at month 5, so the four pairs B vs C, B vs D, B vs E, B vs F are usable. Against C the model is wrong, since , giving one discordant pair. Against D, E, F the scores are , all below , giving three concordant pairs. Running total: eight concordant, one discordant.
C is censored, so the three pairs C vs D, C vs E, C vs F are excluded. D fails at month 8, so D vs E and D vs F are usable. Against E, is discordant. Against F, is concordant. Running total: nine concordant, two discordant. The remaining pair, E vs F, has both members censored and is excluded.
That gives eleven usable pairs, of which nine are concordant and two discordant, so
Thus : roughly four times in five, when the data tell us which of two units failed first, the model agrees. Note that the four excluded pairs are not a random sample of the fifteen: they all involve C or E, two of the three censored units, so any claim about the model's accuracy in the late-failure region rests on very little evidence.
For the teaching calculation we fix months, within positive censoring support. All eleven usable pairs have earlier failures before this cutoff. Their failure times do not tie censorings, so and the product weight reduces to . The censoring survival function comes from the same Kaplan–Meier machinery applied to the censoring indicator:
The step at month 6 comes from one censoring event among the four units still under observation, so the curve drops from 1 to . At month 9 one of the two remaining units is censored, so it drops further to . At month 12 the last unit is censored and the estimate reaches zero, so the strict Brier implementation used here rejects horizons at or beyond month 12. Censoring exactly at month 12 still establishes survival past that boundary; using that outcome would require the inclusive survivor selection and its matching left-limit censoring weight.
The pair weights are applied to the earlier member of each pair. A's pairs get weight each, contributing to the numerator and to the denominator. B's pairs get each, contributing to the numerator (three concordant) and to the denominator (four usable pairs). D's pairs get each, contributing and . Therefore
The IPCW version is lower because D's pairs, one of which is discordant, now carry extra weight relative to the others. The two numbers answer slightly different questions: Harrell targets the mix of pairs this study's censoring pattern allowed us to resolve, while truncated IPCW targets true failure pairs whose earlier failure precedes the specified cutoff, under the censoring assumptions.
Working through the IPCW Brier score by hand
Compute the Graf contributions in code so every displayed value comes from the data, then check them against the formulas.
PythonIn [16]Expand
tiny_g_at_t = step_value(tiny_times, tiny_g_times, tiny_g, side="left")
tiny_alive = tiny_times > tau_tiny
tiny_failed = (tiny_times <= tau_tiny) & (tiny_event == 1)
tiny_terms = np.zeros_like(tiny_times)
tiny_terms[tiny_failed] = tiny_pred[tiny_failed] ** 2 / tiny_g_at_t[tiny_failed]
tiny_terms[tiny_alive] = (1.0 - tiny_pred[tiny_alive]) ** 2 / g_tau
tiny_ipcw = float(tiny_terms.mean())ConsoleOut [17]Expand
unit A: IPCW contribution = 0.3025 unit B: IPCW contribution = 0.3844 unit C: IPCW contribution = 0.0000 unit D: IPCW contribution = 0.7500 unit E: IPCW contribution = 0.0533 unit F: IPCW contribution = 0.0300 IPCW Brier at tau = 8 = 0.2534
The table shows each unit's contribution, with censored units before the horizon contributing zero and the observed units carrying their weights.
Go unit by unit. A failed at month 3, before , so the outcome is 0 and the squared error is ; its weight is , giving . B failed at month 5, squared error , weight , giving . C was censored at month 6, before , so its outcome is unknown and it contributes . D failed exactly at , which counts as a failure by , so its outcome is 0 and the error is ; the weight is , giving . E and F both outlived , so their outcome is 1. E contributes , and F contributes .
Summing and dividing by :
PythonIn [18]Expand
tiny_naive = contrib_naive.mean()
tiny_drop = float(
contrib_naive[~((tiny_times <= tau_tiny) & (tiny_event == 0))].mean()
)ConsoleOut [19]Expand
IPCW Brier at tau = 8: 0.2534 Censored unit scored as survivor: 0.2337 Censored unit dropped: 0.2624
Scoring the censored unit as a survivor gives the model unearned credit for an outcome nobody observed, and dropping the censored unit conditions the sample on its horizon outcome being observable, which changes the question being asked. Under the stated censoring assumptions, IPCW targets the complete-outcome Brier error without imputing the unknown outcomes; finite-sample estimation error remains.
Verifying the arithmetic with code
The same two calculations, written as explicit loops over pairs, confirm the hand arithmetic and give us a reference to check the vectorised implementation against.
PythonIn [20]Expand
concordant = 0
discordant = 0
for i in range(len(tiny)):
for j in range(len(tiny)):
if tiny_event[i] == 1 and tiny_times[i] < tiny_times[j]:
if tiny_risk[i] > tiny_risk[j]:
concordant += 1
elif tiny_risk[i] < tiny_risk[j]:
discordant += 1
hand_c = concordant / (concordant + discordant)
tau_star_tiny = 10.0
pair_weights = []
pair_scores = []
for i in range(len(tiny)):
if tiny_event[i] == 1 and tiny_times[i] < tau_star_tiny:
assert g_before_t[i] > 0 and g_at_t[i] > 0
weight = 1.0 / (g_before_t[i] * g_at_t[i])
for j in range(len(tiny)):
if tiny_times[i] < tiny_times[j]:
pair_weights.append(weight)
pair_scores.append(weight * (tiny_risk[i] > tiny_risk[j]))
hand_uno = float(np.sum(pair_scores) / np.sum(pair_weights))
hand_brier = contrib_ipcw.mean()ConsoleOut [21]Expand
usable pairs : 11 (concordant 9, discordant 2) Harrell C : 0.8182 Uno C : 0.7788 IPCW Brier at tau=8 : 0.2534 naive Brier at tau=8: 0.2337
Three hand calculations, three confirmations. With the definitions validated on a cohort we can inspect, we can now scale up to a realistic sample where the models themselves must be fitted.
Implementation in Python
The six-unit cohort validates the arithmetic, but it cannot tell us how the evaluation behaves as a statistical estimator. We therefore simulate a realistic equipment cohort with a known generating mechanism, fit three models on a training partition, estimate the censoring weights on that same training partition, and score the fitted models on a test partition they never saw. The generating Weibull AFT model also has proportional hazards, so both fitted model families have appropriate population forms. Their finite-sample estimates and held-out metrics can still differ.
Simulating a censored equipment cohort
PythonIn [22]Expand
rng = np.random.default_rng(20240501)
n_units = 900
temp_z = rng.normal(size=n_units)
cycles_z = rng.normal(size=n_units)
preventive = rng.binomial(1, 0.40, size=n_units)
log_scale = np.log(1500.0) - 0.35 * temp_z - 0.45 * cycles_z + 0.30 * preventive
rho_true = 1.6
t_failure = np.exp(log_scale) * (-np.log(rng.uniform(size=n_units))) ** (
1.0 / rho_true
)
t_dropout = rng.exponential(scale=2500.0, size=n_units)
t_admin = 3600.0
t_censor = np.minimum(t_dropout, t_admin)
equipment = pd.DataFrame(
{
"temp_z": temp_z,
"cycles_z": cycles_z,
"preventive": preventive,
"time": np.minimum(t_failure, t_censor),
"event": (t_failure <= t_censor).astype(int),
}
)The failure times follow with and a standard Gumbel random variable for minima, so hotter and busier units fail sooner and preventive maintenance extends life. Equivalently, the shape parameter is , matching in the simulation. Censoring comes from two sources: an exponential dropout process with a 2,500-hour mean, representing units that are retired or lose their sensor feed, and an administrative cut at 3,600 hours when the study closes. Both censoring sources are generated independently of failure times and prediction covariates, which supports the pooled censoring weights used below. The controlled simulation lets us check fitting and evaluation separately under stated assumptions. Agreement here does not establish validity under a different censoring mechanism or population.
ConsoleOut [23]Expand
temp_z cycles_z preventive time event -0.326967 1.040655 0 145.142787 1 -0.974315 0.307145 0 1002.431850 0 0.494588 0.895817 0 432.954549 1 units : 900 observed failures : 490 (54.4%) censored observations : 410 (45.6%) median follow-up : 784 h units censored at the 3600 h cut: 25
Roughly 45 percent of this cohort is censored. That overall fraction alone does not determine the weight stability or the bias of a naive rule; inspect the censoring survival at the scored horizons.
Splitting, standardising, and fitting the three models
The split is part of the evaluation design, not an afterthought. We keep a training partition for fitting, a validation partition for any model-choice step, and a test partition reserved for a final, prespecified evaluation phase.
PythonIn [24]Expand
features = ["temp_z", "cycles_z", "preventive"]
continuous = ["temp_z", "cycles_z"]
order_idx = rng.permutation(len(equipment))
train = equipment.iloc[order_idx[:540]].reset_index(drop=True)
val = equipment.iloc[order_idx[540:720]].reset_index(drop=True)
test = equipment.iloc[order_idx[720:]].reset_index(drop=True)
mu_train = train[continuous].mean()
sd_train = train[continuous].std(ddof=0)
def design(frame):
"""Standardise continuous columns with training statistics; keep the indicator raw."""
scaled = (frame[continuous] - mu_train) / sd_train
return np.column_stack(
[
scaled.to_numpy(dtype=float),
frame["preventive"].to_numpy(dtype=float),
]
)
X_train, X_val, X_test = design(train), design(val), design(test)
t_train = train["time"].to_numpy(dtype=float)
e_train = train["event"].to_numpy(dtype=float)
t_val = val["time"].to_numpy(dtype=float)
e_val = val["event"].to_numpy(dtype=float)
t_test = test["time"].to_numpy(dtype=float)
e_test = test["event"].to_numpy(dtype=float)ConsoleOut [25]Expand
partition units events censoring rate
train 540 290 0.463
validation 180 94 0.478
test 180 106 0.411Standardisation uses training means and standard deviations only, so no test-set information leaks into the design matrix. The preventive-maintenance indicator stays at 0/1 because rescaling a binary column would make its coefficient awkward to interpret.
The Cox partial likelihood is evaluated with Breslow's handling of tied event times, in a single function that returns the log-likelihood, its gradient, and its Hessian simultaneously.
PythonIn [26]Expand
def cox_pl(beta, X, time, event):
"""Breslow partial log-likelihood, gradient, and Hessian for Cox regression."""
order = np.argsort(time, kind="mergesort")
X_s, t_s, d_s = X[order], time[order], event[order]
eta = X_s @ beta
w = np.exp(eta)
risk_sum = np.cumsum(w[::-1])[::-1]
wx = w[:, None] * X_s
risk_x = np.cumsum(wx[::-1], axis=0)[::-1]
risk_xx = np.cumsum((wx[:, :, None] * X_s[:, None, :])[::-1], axis=0)[::-1]
ev = np.flatnonzero(d_s == 1)
starts = np.searchsorted(t_s, t_s[ev], side="left")
loglik = float(np.sum(eta[ev] - np.log(risk_sum[starts])))
mean_x = risk_x[starts] / risk_sum[starts][:, None]
grad = np.sum(X_s[ev] - mean_x, axis=0)
hess = -np.sum(
risk_xx[starts] / risk_sum[starts][:, None, None]
- mean_x[:, :, None] * mean_x[:, None, :],
axis=0,
)
return loglik, grad, hess
def fit_cox(X, time, event, ridge=1e-6, n_iter=40, tol=1e-10):
"""Newton-Raphson on the Breslow partial likelihood, with a tiny ridge term."""
beta = np.zeros(X.shape[1])
trace = []
for _ in range(n_iter):
loglik, grad, hess = cox_pl(beta, X, time, event)
trace.append(loglik)
step = np.linalg.solve(-hess + ridge * np.eye(X.shape[1]), grad)
beta = beta + step
if np.max(np.abs(step)) < tol:
break
return beta, np.array(trace)The gradient sums over event times. Each contribution compares the failing unit's covariates with the risk-weighted average among units still alive at that time. Here:
- : the covariate vector of the unit that failed at
- : the average of the risk-set covariates weighted by , namely
If failing units have higher temperatures than their risk-weighted risk-set averages, their contributions to the temperature score are positive. The joint Newton coefficient update also depends on the Hessian and the other covariates. The Hessian provides the curvature that turns this comparison into a Newton step.
PythonIn [27]Expand
def cox_baseline(X, time, event, beta):
"""Breslow baseline cumulative hazard, returned as a step function."""
order = np.argsort(time, kind="mergesort")
X_s, t_s, d_s = X[order], time[order], event[order]
risk_sum = np.cumsum(np.exp(X_s @ beta)[::-1])[::-1]
uniq, first, _ = np.unique(t_s, return_index=True, return_counts=True)
events_at = np.add.reduceat(d_s, first)
return uniq, np.cumsum(events_at / risk_sum[first])
def cox_survival(tau, X, beta, h0_times, h0_vals):
"""Cox survival probabilities at a single horizon tau for every row of X."""
h0 = step_value(tau, h0_times, h0_vals, default=0.0)
return np.exp(-h0 * np.exp(X @ beta))
def weibull_survival(tau, X, mu, gamma, rho):
"""Weibull AFT survival probabilities at a single horizon tau for every row of X."""
if tau == 0:
return np.ones(X.shape[0], dtype=float)
log_lam = mu + X @ gamma
return np.exp(-np.exp(rho * (np.log(tau) - log_lam)))
def fit_weibull_aft(X, time, event):
"""Maximum likelihood for a Weibull AFT model with independent censoring."""
time = np.asarray(time, dtype=float)
event = np.asarray(event, dtype=float)
log_t = np.log(time)
ev = event == 1
design_ev = np.column_stack([np.ones(ev.sum()), X[ev]])
init, *_ = np.linalg.lstsq(design_ev, log_t[ev], rcond=None)
theta0 = np.concatenate([init[:1], init[1:], [np.log(1.5)]])
def neg_loglik(theta):
mu_, gamma_ = theta[0], theta[1 : 1 + X.shape[1]]
rho_ = np.exp(theta[-1])
log_lam = mu_ + X @ gamma_
z = np.exp(rho_ * (log_t - log_lam))
ll = np.sum(
event * (theta[-1] + (rho_ - 1.0) * (log_t - log_lam) - log_lam - z)
)
ll -= np.sum((1.0 - event) * z)
return -ll
res = optimize.minimize(
neg_loglik,
theta0,
method="L-BFGS-B",
options={"maxiter": 5000, "ftol": 1e-12, "gtol": 1e-9},
)
return res.x[0], res.x[1 : 1 + X.shape[1]], float(np.exp(res.x[-1])), resThe AFT likelihood is built from the two contributions we derived earlier. A unit observed to fail at contributes its log density , and a censored unit contributes only , where and . The expression inside neg_loglik multiplies the first by the event indicator and adds the second contribution for the non-events, so one formula covers both cases.
PythonIn [28]Expand
beta_hat, cox_trace = fit_cox(X_train, t_train, e_train)
h0_times, h0_vals = cox_baseline(X_train, t_train, e_train, beta_hat)
mu_hat, gamma_hat, rho_hat, aft_res = fit_weibull_aft(X_train, t_train, e_train)
km_times, km_surv, _ = km_curve(t_train, e_train)ConsoleOut [29]Expand
Cox log hazard ratio AFT gamma implied by AFT: -rho * gamma temp_z 0.600 -0.366 0.581 cycles_z 0.690 -0.423 0.671 preventive -0.522 0.314 -0.498 AFT scale exp(mu) at the reference profile : 1586 h AFT shape rho : 1.587 Cox Newton iterations : 5 AFT optimizer converged : True
The fitted coefficient patterns are close in this seeded Weibull example. The Cox log hazard ratio for a covariate is close to times the AFT coefficient, since a Weibull hazard of the form can be written as a baseline hazard multiplied by , which is a proportional hazards model with . The fitted shape is close to the generating shape of 1.6. At zero standardized continuous covariates and preventive = 0, the fitted scale is about 1,586 hours, compared with a generating scale of about 1,552 hours at the training-mean covariate profile. The simulation's 1,500-hour baseline refers to zero raw covariates and preventive = 0. This agreement checks the fitted coefficient pattern in this seeded example; the derivative and reference-distribution checks below test additional calculations.
Computing the evaluation metrics
We estimate the censoring distribution once, on the training partition, and reuse it everywhere downstream. Fixing the horizons before touching the test set keeps the evaluation honest.
PythonIn [30]Expand
g_times, g_surv = censoring_curve(t_train, e_train)
horizons = np.array([800.0, 1500.0, 2500.0])
grid = np.linspace(300.0, 3000.0, 28)
tau_star = 3000.0 # preselected earlier-failure cutoff for IPCW concordance
g_at_horizons = step_value(horizons, g_times, g_surv)ConsoleOut [31]Expand
tau = 800 h G_hat(tau) = 0.709 weight 1/G_hat = 1.41 tau = 1500 h G_hat(tau) = 0.547 weight 1/G_hat = 1.83 tau = 2500 h G_hat(tau) = 0.378 weight 1/G_hat = 2.64 G_hat(3000 h) = 0.312 events at or before 2500 h in the test partition: 104 units still at risk at 2500 h in the test partition: 10
Both positivity requirements hold at all three horizons: , , and , each positive. At 2,500 hours, the retained test output reports 104 observed failures at or before the horizon and 10 units observed strictly beyond it. Positive censoring support makes the weights defined; it does not establish the precision of the score. Report these counts with uncertainty rather than treating a positive estimate as an adequacy threshold. At or beyond 3,600 hours, the administrative censoring jump makes , so this strict IPCW Brier implementation rejects the horizon. That support restriction does not make the outcome of a unit censored exactly at the cut unknown; it excludes an inclusive boundary estimator from this implementation.
PythonIn [32]Expand
def _metric_inputs(values, time, event):
time, event = _survival_inputs(time, event)
values = np.asarray(values, dtype=float)
if values.shape != time.shape or not np.all(np.isfinite(values)):
raise ValueError(
"predictions must be finite and aligned with observations"
)
return values, time, event
def _censoring_values(query, g_times, g_surv, side="right"):
g_times = np.asarray(g_times, dtype=float)
g_surv = np.asarray(g_surv, dtype=float)
if g_times.size == 0 or g_surv.shape != g_times.shape:
raise ValueError("a nonempty aligned censoring curve is required")
if (
not np.all(np.isfinite(g_surv))
or np.any((g_surv < 0) | (g_surv > 1))
or np.any(np.diff(g_surv) > 0)
):
raise ValueError(
"censoring survival must be finite, nonincreasing, and in [0,1]"
)
if np.any(np.asarray(query) > g_times[-1]):
raise ValueError(
"evaluation extends beyond observed censoring follow-up"
)
result = step_value(query, g_times, g_surv, side=side)
if np.any(np.asarray(result) <= 0):
raise ValueError(
"censoring survival must be positive at contributing times"
)
return result
def harrell_c(risk, time, event):
"""Comparable-pair concordance, giving half credit to tied risk scores."""
risk, time, event = _metric_inputs(risk, time, event)
usable = (event[:, None] == 1) & (time[:, None] < time[None, :])
if not usable.any():
raise ValueError("concordance requires at least one comparable pair")
higher = usable & (risk[:, None] > risk[None, :])
tied = usable & (risk[:, None] == risk[None, :])
return float((higher.sum() + 0.5 * tied.sum()) / usable.sum())
def uno_c(risk, time, event, g_times, g_surv, tau_star):
"""Truncated IPCW concordance; continuous censoring gives Uno's G^-2 weight."""
risk, time, event = _metric_inputs(risk, time, event)
if not np.isfinite(tau_star) or tau_star <= 0:
raise ValueError("tau_star must be a fixed positive finite horizon")
_censoring_values(tau_star, g_times, g_surv)
usable = (
(time[:, None] < time[None, :])
& (event[:, None] == 1)
& (time[:, None] < tau_star)
)
if not usable.any():
raise ValueError("truncated concordance requires a comparable pair")
earlier = usable.any(axis=1)
g_before = _censoring_values(time[earlier], g_times, g_surv, side="left")
g_after = _censoring_values(time[earlier], g_times, g_surv)
weights = np.zeros_like(time)
log_weights = -np.log(g_before) - np.log(g_after)
weights[earlier] = np.exp(log_weights - log_weights.max())
pair_weights = np.where(usable, weights[:, None], 0.0)
credit = (risk[:, None] > risk[None, :]) + 0.5 * (
risk[:, None] == risk[None, :]
)
return float((pair_weights * credit).sum() / pair_weights.sum())
def ipcw_brier(surv_pred, time, event, tau, g_times, g_surv):
"""IPCW Brier score: failure weights use G(t-), survivor weights G(tau)."""
surv_pred, time, event = _metric_inputs(surv_pred, time, event)
if (
np.any((surv_pred < 0) | (surv_pred > 1))
or not np.isfinite(tau)
or tau < 0
):
raise ValueError(
"survival predictions must be probabilities and tau nonnegative"
)
g_tau = float(_censoring_values(tau, g_times, g_surv))
failed = (time <= tau) & (event == 1)
alive = time > tau
g_before = _censoring_values(time[failed], g_times, g_surv, side="left")
terms = np.zeros_like(time)
terms[failed] = surv_pred[failed] ** 2 / g_before
terms[alive] = (1.0 - surv_pred[alive]) ** 2 / g_tau
return float(terms.mean())
def naive_brier(surv_pred, time, event, tau):
"""Brier score that treats every censored-before-tau unit as a survivor."""
time = np.asarray(time, dtype=float)
event = np.asarray(event, dtype=float)
outcome = 1.0 - ((time <= tau) & (event == 1)).astype(float)
return float(np.mean((outcome - np.asarray(surv_pred, dtype=float)) ** 2))
def trapezoid(y, x):
"""Trapezoid rule, written out so the integral is visible."""
return float(np.sum(0.5 * (y[1:] + y[:-1]) * np.diff(x)))
def predict_survival(tau, model):
"""Survival probabilities at one horizon for every test unit."""
if model == "Kaplan-Meier":
return np.full(len(test), float(step_value(tau, km_times, km_surv)))
if model == "Cox PH":
return cox_survival(tau, X_test, beta_hat, h0_times, h0_vals)
return weibull_survival(tau, X_test, mu_hat, gamma_hat, rho_hat)For Uno's ratio, the code evaluates relative row weights. It computes for failures with at least one eligible partner, then uses over those same contributing rows. Multiplying all contributing weights by one common positive factor leaves the numerator-to-denominator ratio unchanged. A row with no eligible partner does not set that scale. This avoids forming an underflowing probability product or an overflowing reciprocal. Extremely small relative weights can still round to zero in floating-point arithmetic.
The risk scores follow directly. The Cox model contributes its linear predictor, the AFT model contributes the negated log-scale term so that larger means higher risk, and the Kaplan–Meier fit contributes a constant, which is the honest summary of a model with no covariates.
PythonIn [33]Expand
model_names = ["Kaplan-Meier", "Cox PH", "Weibull AFT"]
risk_scores = {
"Kaplan-Meier": np.zeros(len(test)),
"Cox PH": X_test @ beta_hat,
"Weibull AFT": -(X_test @ gamma_hat),
}
surv_at = {}
for name in model_names:
for tau in horizons:
surv_at[(name, tau)] = predict_survival(tau, name)
val_surv_1500 = {
"Cox PH": cox_survival(1500.0, X_val, beta_hat, h0_times, h0_vals),
"Weibull AFT": weibull_survival(1500.0, X_val, mu_hat, gamma_hat, rho_hat),
}
val_brier = {
name: ipcw_brier(pred, t_val, e_val, 1500.0, g_times, g_surv)
for name, pred in val_surv_1500.items()
}The validation comparison is a small selection step: both covariate models are scored on the validation partition at the 1,500-hour horizon, and whichever is better would be the one we carry forward. The test partition is not consulted for this choice. The table below shows the result.
ConsoleOut [34]Expand
Validation IPCW Brier at tau = 1500 h Cox PH : 0.1611 Weibull AFT : 0.1616 Kaplan-Meier marginal baseline: 0.2498
PythonIn [35]Expand
rows = []
for name in model_names:
row = {
"model": name,
"Harrell C": harrell_c(risk_scores[name], t_test, e_test),
"Uno C (<3000 h)": uno_c(
risk_scores[name], t_test, e_test, g_times, g_surv, tau_star
),
}
for tau in horizons:
row[f"Brier {int(tau)}h"] = ipcw_brier(
surv_at[(name, tau)], t_test, e_test, tau, g_times, g_surv
)
rows.append(row)
metrics = pd.DataFrame(rows).set_index("model").round(4)ConsoleOut [36]Expand
Harrell C Uno C (<3000 h) Brier 800h Brier 1500h Brier 2500h model Kaplan-Meier 0.5000 0.5000 0.2614 0.2377 0.1366 Cox PH 0.7095 0.7110 0.1972 0.1968 0.0935 Weibull AFT 0.7100 0.7116 0.1991 0.1928 0.0889
The bars below compare the three chosen decision horizons within each model, using the rounded values in the table. The later horizon curves reuse those same horizons within a denser grid and add the naive censoring rule, so they show the path and the effect of outcome imputation rather than a separate evaluation sample.
VisualizationOut [38]Expand

PythonIn [39]Expand
brier_curves = {}
for name in model_names:
brier_curves[name] = np.array(
[
ipcw_brier(
predict_survival(t, name), t_test, e_test, t, g_times, g_surv
)
for t in grid
]
)
brier_curve_naive = np.array(
[
naive_brier(predict_survival(t, "Weibull AFT"), t_test, e_test, t)
for t in grid
]
)
ibs = {
name: trapezoid(brier_curves[name], grid) / (grid[-1] - grid[0])
for name in model_names
}ConsoleOut [40]Expand
Integrated Brier score over 300-3000 h Kaplan-Meier : 0.1986 Cox PH : 0.1513 Weibull AFT : 0.1482 Naive versus IPCW for the Weibull AFT model tau = 800 h IPCW 0.1991 naive 0.1767 tau = 1500 h IPCW 0.1928 naive 0.2144 tau = 2500 h IPCW 0.0889 naive 0.2330
In this test cohort, Kaplan–Meier lands at exactly 0.5 on both concordance measures because a constant score produces nothing but ties, and its IPCW Brier scores are 0.2614, 0.2377, and 0.1366 at 800, 1,500, and 2,500 hours. For comparison, Weibull AFT scores 0.1928 at 1,500 hours. A marginal curve can be roughly right on average while being useless for ranking. Both covariate models improve on it for discrimination. Report ranking and probability accuracy separately when the decision needs both. The naive Brier rule imputes survival to unknown horizon outcomes. Its difference from IPCW in this finite sample reflects both the effect of that imputation and sampling variation; it is not a direct measurement of the population bias.
VisualizationOut [42]Expand

The censoring curve supplies the weights for IPCW Brier error and truncated IPCW concordance. It declines gradually as units drop out, which inflates the weights modestly, and then it hits zero at the 3,600-hour administrative cut, where every remaining unit is censored at once. The jump records the end of observation. Units censored exactly at the cut are known to have survived past it, but the strict estimator here excludes them and has zero right-side censoring support at the cut. Follow-up alone does not reveal their subsequent failure times, so evaluating later outcomes would require additional assumptions or observation.
VisualizationOut [44]Expand

Three features of this figure carry the argument. First, the naive and IPCW curves differ because they use different rules for unknown horizon outcomes. Their difference in this finite sample combines censoring bias with sampling variation, and its direction can change with the horizon. Second, the two covariate models separate visibly from Kaplan–Meier in the middle of this plotted range. That visual separation does not establish where a comparison has greatest statistical power. Third, each covariate model has its largest plotted IPCW Brier value at 1,100 h. The IPCW curves have lower values toward the end of this window overall. Brier levels at different horizons reflect changing binary-outcome uncertainty as well as forecast performance, so the peak does not isolate the worst calibration. Near the end of follow-up, shrinking survivor counts and small censoring survival make the estimates sensitive to a few weighted contributions; report that support when interpreting the comparison.
PythonIn [45]Expand
def calibration_bins(surv_pred, time, event, tau, n_bins=5):
"""Group units by predicted survival and compare with Kaplan-Meier within each group."""
surv_pred = np.asarray(surv_pred, dtype=float)
order = np.argsort(surv_pred)
predicted, observed, lower, upper = [], [], [], []
for idx in np.array_split(order, n_bins):
u, s, v = km_curve(time[idx], event[idx])
obs = float(step_value(tau, u, s))
greenwood = float(step_value(tau, u, v, default=np.nan))
has_failure = np.any((event[idx] == 1) & (time[idx] <= tau))
se = (
float(np.sqrt(greenwood))
if has_failure and np.isfinite(greenwood) and greenwood > 0
else np.nan
)
predicted.append(surv_pred[idx].mean())
observed.append(obs)
lower.append(obs - 1.96 * se)
upper.append(obs + 1.96 * se)
return tuple(np.array(arr) for arr in (predicted, observed, lower, upper))
tau_cal = 1500.0
cal_cox = calibration_bins(
surv_at[("Cox PH", tau_cal)], t_test, e_test, tau_cal
)
cal_aft = calibration_bins(
surv_at[("Weibull AFT", tau_cal)], t_test, e_test, tau_cal
)
ici_cox = float(np.mean(np.abs(cal_cox[1] - cal_cox[0])))
ici_aft = float(np.mean(np.abs(cal_aft[1] - cal_aft[0])))ConsoleOut [46]Expand
Mean absolute calibration error at tau = 1500 h Cox PH : 0.1645 Weibull AFT : 0.1556
VisualizationOut [48]Expand

The mean absolute deviations are about 0.165 for Cox and 0.156 for Weibull AFT. Several quintiles sit appreciably below the diagonal; for example, the fourth Cox quintile predicts about 0.662 survival while its Kaplan–Meier estimate is about 0.302. The data were generated by a Weibull model and the Cox specification is correctly proportional. The error bars matter as much as the points. Each quintile contains 36 units, with 28, 23, 18, 15, and 9 observed failures by 1,500 hours in the Cox quintiles, so the observed value carries real uncertainty. For quintiles whose approximate pointwise 95 percent intervals cross the diagonal, the corresponding pointwise Wald comparison does not reject equality at the 5 percent level under the normal approximation, treating the fitted forecasts and binning as fixed. This does not establish calibration, demonstrate equivalence, or rule out smaller departures. These intervals are not a simultaneous test across quintiles. Show the intervals so that readers can assess the precision of the displayed estimates.
For this display, a bin's normal interval is unavailable when no failure is observed by the horizon or its Greenwood variance is nonpositive or nonfinite; the Kaplan–Meier point estimate is still shown. Passing these checks does not establish approximate coverage when failures are few.
Key Parameters
The list below covers evaluation settings and numerical controls used in this example.
horizons(): evaluation cut points. Fix them before seeing test results and tie them to a decision (inspection interval, warranty term, budget cycle). This strict IPCW Brier estimator requires . Report the number of observed failures by each horizon and the number of units observed strictly beyond it, and assess score uncertainty; positivity alone does not establish precision.- Censoring-weight estimation sample: this example fits the censoring curve on training data and keeps it fixed for evaluation. Evaluation-cohort estimation can also be valid under the censoring assumptions; include its estimation variation in uncertainty and check that the chosen censoring model describes the scored population.
n_bins: number of calibration bins (quintiles give 36 units per bin here). Inspect each bin's sample size, failures before the horizon, follow-up beyond it, censoring support, event-time risk sets, and Greenwood-based uncertainty interval before interpreting the point. Choose an acceptable interval width from the decision context. Fewer bins increase average group size but can hide within-bin miscalibration; an event-count target alone does not establish precision. The last Cox quintile has nine observed failures here.ridge: a diagonal damping term in each Newton solve (default: ). It stabilises the numerical step but does not penalise the objective or shrink the converged coefficients. An L2 penalty would also subtract from the score.- Convergence tolerance (
tol): maximum absolute Newton step below which fitting stops. The default value is ; this is a numerical stopping criterion and does not establish the statistical precision of the coefficient estimates.
Key Methods
km_curvereturns Kaplan–Meier survival and Greenwood variance at distinct observed times. Its undefined risk-exhausting variance is missing.censoring_curveestimates reverse Kaplan–Meier with original failures removed before a tied censoring jump.step_valueevaluates an aligned, sorted step table at query times;side="right"gives its right value andside="left"the value immediately before a jump.cox_plandfit_coxcompute the Breslow partial likelihood, gradient, and Hessian, then solve for the coefficients by Newton–Raphson.cox_baselineandcox_survivalturn the fitted coefficients into predicted survival probabilities through .fit_weibull_aftandweibull_survivaldo the same for the AFT parameterisation, optimising the censored log-likelihood with L-BFGS-B and converting into survival probabilities.harrell_ccomputes comparable-pair concordance from risk scores;uno_calso requires a censoring curve and an explicit supported truncation horizon.ipcw_brier,naive_brier, andtrapezoidproduce the IPCW Brier score, the naive survivor-imputed score, and the integrated summary.calibration_binsgroups units by predicted survival and compares each group's predictions with its Kaplan–Meier estimate.
Verifying the implementation
Three complementary checks compare selected implementation calculations with finite differences, explicit loops, and a reference distribution. The first compares the analytic Cox gradient with a finite-difference approximation. The second reproduces the six-unit hand arithmetic. The third compares our Weibull survival function with SciPy's reference implementation of the same distribution.
PythonIn [49]Expand
beta_check = np.array([0.10, -0.20, 0.05])
_, grad_analytic, _ = cox_pl(beta_check, X_train, t_train, e_train)
eps = 1e-6
grad_numeric = np.empty_like(beta_check)
for k in range(len(beta_check)):
bump = np.zeros_like(beta_check)
bump[k] = eps
plus = cox_pl(beta_check + bump, X_train, t_train, e_train)[0]
minus = cox_pl(beta_check - bump, X_train, t_train, e_train)[0]
grad_numeric[k] = (plus - minus) / (2 * eps)
grad_error = float(np.max(np.abs(grad_analytic - grad_numeric)))
tiny_c_check = harrell_c(tiny_risk, tiny_times, tiny_event)
tiny_brier_check = ipcw_brier(
tiny_pred, tiny_times, tiny_event, tau_tiny, tiny_g_times, tiny_g
)
scale_ref = np.exp(mu_hat + X_test @ gamma_hat)
scipy_surv = stats.weibull_min.sf(1500.0, c=rho_hat, scale=scale_ref)
ours_surv = weibull_survival(1500.0, X_test, mu_hat, gamma_hat, rho_hat)
surv_error = float(np.max(np.abs(scipy_surv - ours_surv)))ConsoleOut [50]Expand
max |analytic - numeric| Cox gradient : 3.27e-07 six-unit Harrell C : 0.8182 six-unit IPCW Brier at tau = 8 : 0.2534 max |Weibull survival - scipy| : 1.11e-16
At the tested coefficient vector, the printed maximum absolute gradient difference checks this derivative with one step size. Step size, function scale, and rounding can affect its error. The six-unit numbers reproduce and to the printed precision, so the vectorised functions agree with the loop-based definitions we wrote by hand. The Weibull survival probabilities agree with SciPy to machine precision at the evaluated inputs, supporting the stated and conversion. This finite comparison does not verify every possible input.
The Cox and transformed AFT coefficient estimates are close, as expected under this Weibull generating model. They need not be identical in a finite sample: Cox estimates its coefficients by partial likelihood without specifying the baseline, then reconstructs a Breslow baseline cumulative hazard from the risk sets and fitted coefficients. The AFT fit instead assumes a Weibull baseline and uses the full censored likelihood.
Uncertainty on the test metrics
A point estimate from a 180-unit test set needs uncertainty. Here we resample test units with replacement and take percentile intervals while holding the fitted models and training censoring curve fixed. These intervals describe test-cohort sampling variation conditional on those fitted objects; they do not include variation from refitting the models or censoring curve.
PythonIn [51]Expand
boot_rng = np.random.default_rng(1234)
n_boot = 200
pred_1500 = {name: surv_at[(name, 1500.0)] for name in model_names}
boot_records = []
for _ in range(n_boot):
pick = boot_rng.integers(0, len(test), len(test))
tb, eb = t_test[pick], e_test[pick]
for name in model_names:
boot_records.append(
{
"model": name,
"c_index": harrell_c(risk_scores[name][pick], tb, eb),
"brier": ipcw_brier(
pred_1500[name][pick], tb, eb, 1500.0, g_times, g_surv
),
}
)
boot = pd.DataFrame(boot_records)ConsoleOut [52]Expand
c_lower c_upper brier_lower brier_upper model Cox PH 0.6596 0.7599 0.1573 0.2397 Kaplan-Meier 0.5000 0.5000 0.2139 0.2659 Weibull AFT 0.6601 0.7603 0.1546 0.2347
The intervals are wide, as they should be. With 180 test units and roughly 41 percent of them censored, the two covariate-model concordance intervals each span about one tenth, and the two covariate models overlap substantially. Reporting a single number and declaring a winner would be overreach; reporting the interval makes the ambiguity part of the result.
Practical Implications
The evaluation metric should be chosen from the decision the model supports. A ranking decision, such as which unit to inspect first, which patient to prioritise, or which loan to review, calls for concordance, because Harrell's concordance averages ranking credit among its comparable observed pairs, with half credit for tied risk scores. Truncated IPCW concordance instead targets latent pairs whose earlier failure precedes its cutoff, under the censoring assumptions. A decision that needs a probability at a named horizon, such as how many spare units to hold, how much reserve to book, or how many transplant slots to plan for, calls for the Brier score at that horizon, because it is a proper scoring rule and penalises predicted probabilities that are wrong. Report both when ranking and probability forecasts matter: a model can rank well while its probabilities are poorly calibrated. Neither number diagnoses every failure, so inspect calibration directly as well.
The comparison in this chapter illustrates the separation. Kaplan–Meier scores exactly 0.5 on concordance, since a constant score produces only ties. Yet its Brier score can be close to covariate models at some horizons. That is not a contradiction. It reflects two different questions: discrimination asks whether the order is right, while accuracy asks whether the numbers are usable. A selection process that optimises only concordance can accept a model whose predicted probabilities are unusable for planning, and a process that optimises only the Brier score can hide a weak ranking behind a favourable marginal fit.
The executed example scores 180 test units with the dense pairwise concordance implementation. A matrix operation still needs quadratic pair storage; the Brier calculation is a weighted mean at each horizon, and bootstrap resampling repeats these calculations. Choose the implementation and resample count for the available resources. Include the supported horizons and uncertainty calculation in the model-validation plan.
Evaluation horizons, calibration, and validation design
The evaluation horizons belong in the analysis plan. Choose them from the decision context, fix them before looking at test results, and record why each one was chosen. A maintenance horizon should match the inspection cycle, a warranty horizon should match the warranty term, and a clinical horizon should reflect a clinical decision within adequately supported study follow-up. For the strict IPCW Brier estimator, the estimated censoring survival must stay positive at the horizon. Separately, report observed failures by the horizon, follow-up strictly beyond it, and uncertainty; there is no universal minimum count that establishes precision for every forecast and decision. Here we score 800, 1,500, and 2,500 hours and integrate over 300–3,000 hours, within positive estimated censoring support. The strict Brier estimator implemented here is undefined when ; an inclusive boundary rule would require its own matched observation indicator and left-limit weight.
Calibration addresses a different question from discrimination and should be checked directly when probabilities are used. A calibration diagnostic at horizon groups units by predicted survival, estimates observed survival within each group, and compares the two. A Kaplan–Meier group estimate requires censoring to be independent within that group. An IPCW alternative needs a censoring model valid for the group and positive support; weighting does not itself solve small-bin uncertainty. Here the display places observed survival against mean predicted survival with a diagonal reference. Its mean absolute deviation is a binned diagnostic, not a complete conditional-calibration test. Whole-curve diagnostics assess additional aspects: with continuous correctly specified survival distributions, the predicted survival evaluated at each true event time is uniform. Distributional calibration examines that property with an appropriate treatment of censored outcomes. It complements fixed-horizon checks rather than guaranteeing better diagnosis or a universal sample-size requirement.
Two checks keep ranking and probability assessment separate. First, do not read a c-index as evidence of calibration. In the Cox and common-shape Weibull models used here, multiplying every predicted hazard by ten leaves the model risk ranking unchanged while changing survival probabilities from to . The same ranking invariance holds for a fixed-horizon score , but need not hold for medians or other curve functionals when curves cross. The changed probabilities can destroy calibration even when the original forecasts were calibrated. Second, keep the validation design clean. Fit models on training, estimate the censoring distribution on training, use the validation partition for choices that could overfit, and reserve the test partition for the final, prespecified evaluation phase. When several models are compared on the same test set, their estimates can be correlated, and selecting the best of many noisy test estimates can make the apparent winner optimistic, just as with any other metric discussed in Multiple Comparisons. Report intervals alongside point estimates, and prefer a nested design when the same data would otherwise be used for tuning and evaluation, following the logic of Hyperparameter Search and Nested Cross-Validation.
Common failure cases
Treating censored durations as observed failures can produce plausible numbers from unobserved outcomes. Dropping the event indicator and entering a censored time as a failure asserts a failure that was not observed. Scoring an early-censored unit as a survivor makes the opposite unsupported imputation. The direction and size of the resulting distortion depend on the predictions and censoring pattern. If conclusions change sharply when censoring weights are switched on, the naive computation was doing substantial work and should not be trusted.
The censoring distribution is a nuisance component of the evaluation estimator. Estimating it on an independent training partition, as we do here, conditions the evaluation on those fitted weights. Estimating it on the evaluation cohort can also be valid under the censoring assumptions; uncertainty calculations then need to include that estimation step. In either design, the censoring model must describe the population being scored.
The metric helpers should reject unsupported evaluation requests before calculating weights: require positive finite censoring survival at every contributing time and horizon, an explicit supported truncation horizon for Uno concordance, and at least one comparable pair for either concordance. Do not replace a zero censoring probability by a tiny positive number. Report horizon support and comparable-pair counts with the results.
Withdrawal related to impending failure can invalidate the independence assumption behind these pooled IPCW weights. The resulting Brier and concordance estimates can target the wrong quantities; a larger sample does not make the censoring assumption true. Investigate why units leave the study before relying on the weights. When censoring is informative, a sensitivity analysis or a model that explicitly represents the dependence is needed; changing the evaluation metric alone cannot solve the problem.
Summary
Evaluating a survival model means scoring two different products with two different instruments. A risk score is evaluated by concordance, which asks whether the model orders failure times correctly on the pairs selected by our strict recorded-time rule: an observed failure at a strictly earlier recorded time than the other unit. Harrell's concordance averages correct orderings over those pairs with ties counted as half, and becomes the ordinary survival concordance when nothing is censored; it is not generally a fixed-horizon binary ROC AUC. Truncated IPCW concordance targets pairs whose earlier failure precedes a supported fixed cutoff. Continuous censoring gives Uno's inverse-square weights; with censoring ties, the implementation uses the corresponding left/right survival product. These weights require the stated censoring assumptions and positive support. A predicted survival curve is evaluated by the Brier score at a horizon, which compares the predicted probability with the realised outcome; the Graf estimator reweights each selected outcome contribution by the reciprocal of its selection probability, so that a unit observed to fail at time stands in for the similar units represented by that observed failure, and each selected unit observed strictly past stands in for .
The two instruments see different things, so report both when ranking and probability accuracy are decision targets. Concordance uses only the ordering and ties of the supplied scalar risk scores, so it does not identify calibration or the full predicted-curve shape. Changing a curve can change concordance if it changes the chosen risk functional's ordering; a strictly increasing real-number transformation of the supplied scores preserves it. The Brier score is a proper probability score, but its expected value combines reliability (calibration), resolution and marginal outcome uncertainty. Resolution subtracts from that uncertainty, so the uncertainty term is not a lower bound on achievable error. It therefore does not identify calibration by itself. In our equipment cohort the marginal Kaplan–Meier model scored exactly 0.5 on both concordance measures; its Brier scores at the reported horizons answer the separate probability-accuracy question.
Fix the evaluation target before computing the score. Fix the horizons from the decision context before seeing test results, verify that the censoring survival is positive across the evaluation window, use a justified censoring model for the evaluation population, and report intervals rather than points. Never treat a censored duration as an observed failure, never read a c-index as evidence that the probabilities are usable, and justify the censoring model's independence and support assumptions for the population scored. Report the population, horizon, decision, and censoring assumptions alongside the comparison so that readers can interpret the result for that setting.
Quiz
Ready to test your understanding? Take this quick quiz to reinforce what you've learned about evaluating survival predictions, censoring-aware metrics, and calibration.
Evaluating Survival Predictions
Join the discussionAsk a question or share what you learned.
Keep exploring
Reference
Citation details
Cite or share this article.

Comments
Add to the conversation
A question, a correction, or an example from your own work.
No comments yet. Be the first to share your thoughts!