The proportional hazards assumption and Schoenfeld residuals
What proportional hazards actually assumes, how to read the cox.zph table, what Schoenfeld residual plots and log-log plots each show you, and what the four repairs — stratification, a split time axis, a time-varying coefficient, RMST — each cost once the assumption fails.
What the assumption actually assumes
The Cox model says: a baseline hazard, multiplied by a number.
Notice that carries no . That means the multiplier is the same number for the whole of follow-up. In the lung model below, the hazard ratio for women is 0.602, and what the model is claiming is that on day 30, on day 300 and on day 900, a woman’s hazard of death is 0.602 times a man’s — every time. That claim is the proportional hazards assumption.
It is not a technical detail you check on the side. It is the precondition for that hazard ratio meaning anything at all. When it fails, coxph() still returns a number, but the number is an average of effects that differed in size — sometimes in direction — across periods, weighted by where the events happened to fall. It does not correspond to the true hazard ratio at any single moment, and its magnitude moves with the length of follow-up: the same treatment followed for three years and for ten years yields different hazard ratios even when nothing about the underlying reality has changed.
Clinical situations where the assumption predictably breaks:
| Situation | How the hazard ratio moves over time |
|---|---|
| Surgery vs conservative management | Early surgical hazard is high (perioperative complications), later hazard is lower, so the ratio starts above 1 and falls below it — the curves cross |
| Immunotherapy vs chemotherapy | Nothing separates for the first few months, then a gap opens — the ratio starts near 1 and shrinks |
| Prognostic factors such as performance status or age | The effect is concentrated early; by the time follow-up is long, the frail patients have already died and the survivors differ less — the ratio drifts toward 1 |
| Vaccine protection | Wanes over time — the ratio drifts toward 1 |
How to check it: Schoenfeld residuals
The idea behind Schoenfeld residuals is direct. At every event time, the model uses everyone’s covariates to predict who is most likely to be the person having the event; the residual is the covariate value of the person who actually had the event, minus the model’s prediction at that moment.
The key property: if hazards really are proportional, these residuals should have no relationship with time — early and late residuals should both scatter around zero. A systematic trend over time means is in fact moving.
In practice you use scaled Schoenfeld residuals, which have a very convenient property: plot them against time and the smoothed curve is an estimate of — you are looking directly at where the coefficient goes. The formal test in cox.zph() asks whether the slope of that line is zero.
Run it yourself
library(survival)
data(cancer, package = "survival")
lung$sex_f <- factor(lung$sex, levels = c(1, 2),
labels = c("Male", "Female"))
fit <- coxph(Surv(time, status) ~ age + sex_f + ph.karno + wt.loss,
data = lung)
zph <- cox.zph(fit)
zph # one row per variable, plus GLOBAL
par(mfrow = c(2, 2)); plot(zph) # the smoothed line is beta(t)
# log(-log) plot: the graphical check for categorical variables,
# parallel lines mean the assumption is holding
plot(survfit(Surv(time, status) ~ sex_f, data = lung),
fun = "cloglog", col = c("#4d6a8c", "#c44d4d"), lwd = 2)
# Repair 1: split the time axis, estimate one coefficient per period
sp <- survSplit(Surv(time, status) ~ ., data = lung, cut = 180,
episode = "period")
coxph(Surv(tstart, time, status) ~ age + sex_f + wt.loss +
ph.karno:strata(period), data = sp)
# Repair 2: make the coefficient a function of time, beta(t) = b0 + b1*log(t)
coxph(Surv(time, status) ~ age + sex_f + wt.loss + ph.karno + tt(ph.karno),
data = lung, tt = function(x, t, ...) x * log(t))
# Repair 3: stratify -- each stratum gets its own baseline hazard
lung$kstrata <- cut(lung$ph.karno, c(-Inf, 70, 80, Inf))
coxph(Surv(time, status) ~ age + sex_f + wt.loss + strata(kstrata), data = lung)
# Repair 4 (replace the effect measure) has its code on B3-07, the page that
# states RMST in fullVerified with R 4.6.0 and survival 3.8.6
import statsmodels.api as sm
from lifelines import CoxPHFitter
lung = sm.datasets.get_rdataset("cancer", "survival").data
d = lung[["time", "status", "age", "sex", "ph.karno", "wt.loss"]].dropna().copy()
d["event"] = (d["status"] == 2).astype(int)
d["female"] = (d["sex"] == 2).astype(int)
d = d.drop(columns=["status", "sex"]).rename(columns={"ph.karno": "ph_karno",
"wt.loss": "wt_loss"})
cph = CoxPHFitter().fit(d, duration_col="time", event_col="event")
# Scaled Schoenfeld test plus residual plots, with remedies printed
cph.check_assumptions(d, p_value_threshold=0.05, show_plots=True)
# Stratified version: each stratum carries its own baseline hazard
d["k_strata"] = (d["ph_karno"] <= 70).map({True: "low", False: "high"})
CoxPHFitter().fit(d, duration_col="time", event_col="event",
strata=["k_strata"]).print_summary()lifelines' check_assumptions() does the same thing (scaled Schoenfeld residuals plus a test) and prints suggested remedies alongside the output.
How to read the cox.zph() table
Here is the four-variable model on survival::lung. The dataset holds 228 patients; after complete-case analysis, 213 enter the model and 151 events are observed:
| Variable | HR | 95% CI | p |
|---|---|---|---|
| Age, per year | 1.016 | 0.996–1.036 | 0.113 |
| Female vs male | 0.602 | 0.428–0.848 | 0.004 |
| Karnofsky score, per point | 0.988 | 0.976–1.000 | 0.048 |
| Weight loss, per kg | 0.997 | 0.985–1.010 | 0.657 |
And what cox.zph() says about it:
| Variable | χ² | df | p |
|---|---|---|---|
| Age, per year | 0.93 | 1 | 0.335 |
| Female vs male | 3.06 | 1 | 0.080 |
| Karnofsky score, per point | 7.38 | 1 | 0.007 |
| Weight loss, per kg | 0.04 | 1 | 0.842 |
| GLOBAL | 10.16 | 4 | 0.038 |
The null hypothesis here is that proportional hazards holds, so a small p is the bad news. For the Karnofsky score p = 0.007 — a clear violation — and the GLOBAL test reaches significance too, at p = 0.038. Sex sits on the boundary at p = 0.080; age and weight loss give no sign of trouble.
figures/scripts/B3-04-ph-assumption.RThe log-log plot: the graphical version for categorical variables
The other classical check plots against . The logic: if two groups do satisfy proportional hazards with hazard ratio , then
so the two curves are a constant vertical distance apart — parallel. Curves that converge, diverge or cross are telling you the assumption is in trouble.
figures/scripts/B3-04-ph-assumption.RWhat to do when it is violated
Start with one question: is the violation in the variable you care about, or in some covariate?
If it is only a covariate — you want a treatment effect and it is age that misbehaves — stratify it away and the cost is small. If the violation is in the exposure or treatment variable itself, you can no longer report a single hazard ratio, because that number has no clear meaning. Four routes, each with a price.
Route 1: stratified Cox
Move the offending variable from covariate to stratum. Each stratum gets its own baseline hazard , and nothing forces strata to be proportional to one another.
Splitting Karnofsky into three strata takes the cox.zph() GLOBAL test from p = 0.038 to p = 0.441 — the assumption is recovered. The hazard ratio for sex becomes 0.572 (0.405–0.809).
The price: the stratifying variable no longer has a hazard ratio. You bought back the assumption and gave up the estimate. Stratification therefore suits variables you think of as “something I need to control for, whose effect size I do not care about”.
Route 2: split the time axis and estimate one hazard ratio per period
Use survSplit() to cut everyone’s follow-up in two at day 180, so the Karnofsky coefficient is estimated separately before and after:
| Period | HR per Karnofsky point | 95% CI | p |
|---|---|---|---|
| Day 0-180 | 0.966 | 0.947–0.987 | 0.001 |
| Day 180+ | 0.998 | 0.983–1.013 | 0.756 |
Now the story is legible: the protective effect of the Karnofsky score is concentrated in the first 180 days (HR 0.966 per point, interval clear of 1), and after day 180 no association is detected (HR 0.998, interval crossing 1). That carries far more information than the single hazard ratio of 0.988 the original model gave, which was in fact a weighted average of these two periods.
The price: you chose the cut point. Looking at the data first and then picking the cut that makes the p value attractive is data dredging. The cut point should come from a clinical rationale — 30 days after surgery, the end of a treatment cycle — and the paper should say how it was decided.
Route 3: make the coefficient a function of time
tt() lets you assume outright and estimate . Here = 0.011 (SE 0.006, p = 0.062) — positive, meaning the coefficient climbs over time, in the same direction the Schoenfeld plot showed.
The price: the form is also an assumption you made. Use or instead and you get different answers. And the output is now two coefficients, which clinical readers find hard to interpret.
Route 4: switch to an effect measure that does not need proportional hazards
The first three routes still repair the Cox model. The fourth replaces the effect measure itself. Restricted mean survival time (RMST) is the area under the Kaplan-Meier curve between 0 and — “on average, how long did people live during the first of follow-up”. It assumes no proportionality of any kind, its unit is days, and it does not ask the two groups to hold a fixed hazard ratio across the whole of follow-up.
RMST is no longer only a repair for a failed assumption. In oncology and heart-failure trials it is now a routine secondary analysis. The full account — where the area comes from, why has to be fixed in advance, how much the conclusion moves when changes, and how to say it as “lived about this many months longer” — is on restricted mean survival time (RMST).
How to read this in a paper
Everything relevant to this page is usually one sentence buried in the Statistical analysis section. Look for three things:
- Whether the assumption was checked at all. No mention, with a hazard ratio as the headline result, is a substantive methodological gap. The sentence usually reads “The proportional hazards assumption was assessed using scaled Schoenfeld residuals”.
- What was done about the result. “The assumption held” is fine; “the assumption was violated, so we used stratification / period-specific estimates / RMST” is fine; “the assumption was violated but we report a single hazard ratio anyway” means you should discount the number yourself.
- Whether the Kaplan-Meier curves cross. This is the check you can do without reading the Methods at all. Two curves that clearly cross mid-follow-up and stay apart afterwards are strong evidence against proportional hazards — if the true hazard ratio were fixed at any value other than 1, the true survival curves could not cross. Remember, though, that what you are looking at is an estimate: once only a handful of people remain at risk, two Kaplan-Meier curves can cross on sampling noise alone, and a crossing in that tail is not evidence of anything. Read the crossing against the numbers-at-risk table — where it happens and how wide it is — then go back to the Methods for the Schoenfeld residual test or a time interaction that confirms it. Once confirmed, the single hazard ratio is an average of two opposing effects, and the log-rank test loses power as well.
Common misuses
| Misuse | Why it is wrong |
|---|---|
| Fitting a Cox model and never checking proportional hazards | When it fails, that hazard ratio corresponds to no real hazard ratio at any timepoint |
Reading a non-significant cox.zph() p as “the assumption holds” | The test has limited power, especially in small samples; judge the shape and size of the departure from the residual plot as well |
| Overhauling the model the moment a p value turns significant in a large database | With a big enough sample, clinically negligible departures are significant; look at how much β(t) actually moves |
| Reporting a single hazard ratio when the curves cross | The number is a weighted average of two effects pointing in opposite directions, with no clean interpretation |
| Drawing a log-log plot for a continuous variable | It has to be cut into groups first, you chose the cuts, and the picture follows them |
| Switching to the log-rank test because the assumption failed | The log-rank test is equally sensitive to the same problem, and less powerful when curves cross |
| Truncating follow-up until the assumption holds | Lets the data set the scope of the analysis, and may discard exactly the period in which the effect appears |
| Choosing the time cut point after seeing the data | Picking cuts post hoc is data dredging; the cut should be fixed in advance on clinical grounds |
| Using RMST but deciding τ afterwards | τ must be pre-specified in the protocol, otherwise it amounts to selecting a favourable result — see RMST |
| Quoting a hazard ratio for a stratifying variable | A stratified Cox model does not estimate the effect of the stratifying variable — that number does not exist |
Reproducing every number on this page
/opt/homebrew/bin/Rscript figures/scripts/B3-04-ph-assumption.RRead the figure
The answer comes from the same statistical output that produced this page's figures, not from a number typed in beside them.
Each row of cox.zph output is one covariate, and the last row is GLOBAL. To judge whether Karnofsky score violates proportional hazards, which p-value should you read?
Show the answer and why
Correct answer: 0.007 - Karnofsky's own row, which tests whether its effect varies with time
Each term is judged on its own row; Karnofsky's is 0.007. 0.080 belongs to female versus male. GLOBAL's 0.038 is the one most often misused: significance there says only that something in the model varies with time, without saying what, and non-significance guarantees nothing about individual terms, because pooling lets one strong violation be diluted by several clean ones. Reading the GLOBAL p-value as a covariate's p-value is the commonest misreading of this table.
Splitting follow-up at day 180 gives Karnofsky two different hazard ratios. So what does the single hazard ratio from the unsplit model represent?
Show the answer and why
Correct answer: 0.988 - it is a weighted average of the two periods and equals neither
When proportional hazards fails, the single hazard ratio does not become a wrong number so much as a number corresponding to no period at all: it averages the period-specific effects with weights set by the event counts, and those weights depend on how long this particular study followed people, so a longer study would report something different. 0.966 covers days 0 to 180 and 0.998 covers day 180 onward. The awkward part is that 0.988 is the figure that reaches the abstract and gets compared across papers.
Same data, same clinical question: replacing Karnofsky with ECOG makes the cox.zph GLOBAL test non-significant. What does that tell you?
Show the answer and why
Correct answer: GLOBAL becomes 0.314 - whether proportional hazards holds depends on how the model is specified, not on the data alone
All that changed was how performance status is coded; the clinical question is identical, and the test reverses: 0.314 against the original 0.038. So "does this violate PH" is really asking whether the model as written violates it. 0.201 is ECOG's own row in the new model, not the global test. This is why cox.zph should not be treated as a pass-or-fail gate - it turns with the specification, while the questions worth asking are how much the effect varies with time and whether that variation changes the conclusion.
Chapters that use this method
Watch next
The Cox proportional hazards model explained
Survival Analysis Part 9 | Cox Proportional Hazards Model
Cox Proportional Hazard Models
【Lecture】L20 Survival Analysis (2)Sources and licences
This page is original writing