Nomograms
The nomogram is the signature figure of the clinical prediction-model literature, and the thing most often misread as a model in its own right. It is not one. It takes an already-fitted model's linear predictor, rescales it into points, and draws it as a ruler — adding no information at all. This page proves that with one patient computed two ways and an exact affine identity across a whole development cohort, then hands the real questions back to internal validation, external validation and calibration.
A nomogram is not a model
In the oncology and surgical literature the nomogram is close to a formatting requirement: fit the model, report the C-index, and finish with that horizontal ruler covered in tick marks. It appears so often that papers write sentences like “we developed a nomogram to predict five-year recurrence” — as though the nomogram were a kind of model.
It is not a model. It takes the linear predictor of a model that has already been fitted and rescales it onto a 0 to 100 point scale, then draws it. No new information enters, and nothing is estimated at the moment the picture is drawn. It is a hand-calculation device: it lets someone with a pen and a straight edge get the same number the software would give.
That makes “is this a good nomogram?” the wrong question. The question is whether the model behind it has been through internal validation, external validation and calibration. The second half of this page hands those three questions back with the answers this site has already measured on the same model.
What the ruler is drawn from
This page deliberately uses the same model as the calibration page: a Cox model with four predictors fitted on survival::rotterdam (2982 patients, 1713 events) at a 5-year horizon, externally validated on survival::gbsg (686 patients, 299 events).
Using the same model is what the argument needs. If a nomogram really is only a change of drawing, then it must not move a single number the calibration page measured.
figures/scripts/B5-09-nomogram.RReading it takes three steps:
- On each predictor axis find the patient’s value and read upwards to the points axis.
- Add the four point scores and locate the sum on the total points axis.
- Read downwards from there to the risk axis at the bottom.
Walking down the ruler once
Take a hypothetical patient: age 55, a 35 mm tumour, 4 positive nodes, grade 3. This patient sits deliberately near the middle of the development cohort, for the reason just given; the script records the same sentence as its selection_rule.
Now read the points off, one axis at a time. Not recomputed from the coefficients — interpolated on the axes of the picture itself, which is the same thing a reader with a ruler does:
| Predictor | This patient | Points read off the axis |
|---|---|---|
| Age at diagnosis (years) | 55 | 6.59 |
| Tumour size (mm) | 35 | 11.64 |
| Positive nodes (count) | 4 | 11.43 |
| Grade 3 histology (1 = yes) | 1 | 13.14 |
| Total points | 42.80 |
Converting 42.80 points back gives a linear predictor of 0.280401, which through the baseline survival function gives a five-year risk of 52.2%.
The other route never touches the figure: multiply the four covariate values by their coefficients and add, giving a linear predictor of 0.280401 and, through the same baseline survival function, 52.2%.
figures/scripts/B5-09-nomogram.RNot just this patient: the whole cohort
One patient agreeing could be a patient chosen to agree. So the script does it for every patient in the development cohort: read each one’s points off the four axes, add them up, and compare against that patient’s own linear predictor.
figures/scripts/B5-09-nomogram.RAcross 2982 patients the largest residual is 4.3e-14 and is 1.000000. Total points is an affine transformation of the linear predictor — a shift and a multiplication — and nothing else:
Here is the linear predictor, is the linear predictor at zero total points, and is how many points one unit of linear predictor is worth. For this model is 39.57 points and is -0.801. It is negative because rms reports a linear predictor centred on the mean covariate values, and the patient scoring zero total points sits at the low end of every axis, well below that mean.
Where the points scale comes from
A change of units needs an exchange rate. The rule rms::nomogram uses is to work out how far each predictor can move the linear predictor over the tick range of its own axis, give the predictor that moves it furthest a full 100-point axis, and scale every other axis by the same factor. The tick range is not the observed range: here the node axis is ticked out to 35 while the largest count in the cohort is 34, and the exchange rate is computed from the former.
In this model the widest axis is the positive node count: its coefficient multiplied by the span of its axis is the largest of the four, so it takes the full scale and the other three axes are shorter: grade 3 is the shortest at 13.14 points, with age just behind at 14.03 — and age is short enough that the script has to hand it an explicit set of ticks, or the defaults crowd into an unreadable band. That fixes the exchange rate at 39.57 points per unit of linear predictor.
There is also an implementation trap that has nothing to do with interpretation and everything to do with a page telling the truth: the package that draws the picture and the package that produced the reported coefficients must be the same fit. nomogram() only understands an rms::cph object, while most papers report coefficients from survival::coxph. The convergence tolerance cph uses by default is the looser of the two, and the coefficients then agree only to the fourth or fifth decimal — enough to make the claim that these are the same model not quite true.
The script behind this page tightens the tolerance and asserts the agreement with stopifnot():
| Predictor | rms::cph | survival::coxph | Difference |
|---|---|---|---|
| Age at diagnosis (years) | 0.005371 | 0.005371 | 2.3e-17 |
| Tumour size (mm) | 0.014708 | 0.014708 | 1.2e-17 |
| Positive nodes (count) | 0.072202 | 0.072202 | 6.9e-17 |
| Grade 3 histology (1 = yes) | 0.332017 | 0.332017 | 4.6e-15 |
The largest of the four differences is 4.6e-15, well inside the threshold the script enforces. If some later change ever pulls the two apart, the script stops instead of quietly shipping a figure of a model that is not the one in the text.
Not the same thing as a Fagan nomogram
The diagnostic accuracy page has a figure that is also called a nomogram. The two share the old idea of replacing arithmetic with geometry, and differ in everything else:
| Fagan nomogram | The nomogram on this page | |
|---|---|---|
| Class of problem | Updating a probability with a test result | Turning a fitted model into a risk |
| Input | Pre-test probability and a likelihood ratio | One patient’s full set of predictor values |
| Operation performed | Multiply the odds by the likelihood ratio | Rescale the linear predictor into points, then into risk |
| What must exist first | A test with a known sensitivity and specificity | A fitted regression model |
| How it is used | Join two points with a straight line and extend it to the third axis | Read vertically from each axis for points, sum, read down for risk |
| Output | Post-test probability | Predicted risk |
The most practical difference is physical: the Fagan figure asks you to draw a slanted line across three axes, while this one only ever asks for vertical alignment followed by addition. Anyone laying a ruler diagonally across a prediction-model nomogram has confused the two.
So what should be asked of the figure
Since the nomogram adds no information, judging it is the same as judging the model behind it. For this model, the site has measured three things:
| Question | This model | Page |
|---|---|---|
| Ranking, development cohort | C-index 0.666 | Internal validation |
| Ranking, external cohort | C-index 0.642 | External validation |
| Whether the numbers themselves are right | Calibration slope 0.691 (95% CI 0.539–0.842) | Calibration |
Discrimination drops a little from development to external validation, which is expected. The row worth stopping on is the last one: the calibration slope is well below 1, and the whole of its 95% confidence interval lies below 1.
A slope below 1 means the predictions are too spread out: high-risk patients are pushed too high and low-risk patients pressed too low. Translated back onto this figure, that is the same sentence in pictorial form — both ends of the ruler are too long. A patient landing at the high end reads a risk above what patients like them actually experienced in survival::gbsg, and a patient at the low end reads one below.
Running it yourself
library(survival); library(rms)
data(cancer, package = "survival")
dev <- rotterdam
dev$rfs_time <- pmin(dev$rtime, dev$dtime)
dev$rfs_event <- as.integer(dev$recur == 1 | dev$death == 1)
dev$size_mm <- c("<=20" = 15, "20-50" = 35, ">50" = 60)[as.character(dev$size)]
dev$grade3 <- as.integer(dev$grade >= 3)
# Labels must be attached BEFORE cph(), or the picture keeps the bare
# variable names -- with no error anywhere.
label(dev$age) <- "Age at diagnosis (years)"
label(dev$size_mm) <- "Tumour size (mm)"
label(dev$nodes) <- "Positive nodes (count)"
label(dev$grade3) <- "Grade 3 histology (1 = yes)"
dd <- datadist(dev); options(datadist = "dd")
# eps defaults to 1e-4, which leaves the coefficients agreeing with coxph
# only to about 1e-5.
fit <- cph(Surv(rfs_time, rfs_event) ~ age + size_mm + nodes + grade3,
data = dev, surv = TRUE, x = TRUE, y = TRUE, eps = 1e-9, tol = 1e-12)
max(abs(coef(fit) - coef(coxph(Surv(rfs_time, rfs_event) ~
age + size_mm + nodes + grade3, data = dev)))) # should be ~1e-15
H <- 5 * 365.25
surv5 <- Survival(fit)
risk5 <- function(lp) 1 - surv5(H, lp)
nom <- nomogram(fit, fun = risk5, funlabel = "5-year risk")
plot(nom, xfrac = 0.34)
# The exchange rate lives in the object's own attributes
info <- attr(nom, "info")
info$sc # points per unit of linear predictor
info$Intercept # linear predictor at zero total points
# One patient, two routes
patient <- data.frame(age = 55, size_mm = 35, nodes = 4, grade3 = 1)
lp_direct <- as.numeric(predict(fit, newdata = patient, type = "lp"))
# Once labels are attached, nom's elements are named by LABEL; a lookup by
# variable name returns NULL, and NULL[["points"]] is NULL, not an error.
ax <- nom[["Positive nodes (count)"]]
p_node <- approx(ax$nodes, ax$points, xout = patient$nodes)$y
# With all four axes read and summed:
# lp_from_points <- total_points / info$sc + info$Intercept
# risk5(lp_from_points) matches risk5(lp_direct) to floating-point precisionVerified against R 4.6.0 with survival 3.8.6 and rms 8.1.1. Two places that fail silently: label() has to be set before cph() runs, because plot.nomogram reads the labels stored in the fit and setting them afterwards quietly leaves the bare variable names on the picture; and once labels exist, nomogram() names its list elements by label, so looking an element up by variable name returns NULL, and NULL[["points"]] is NULL rather than an error.
import numpy as np, pandas as pd
from lifelines import CoxPHFitter
# The same cohort harmonisation and model fit as B5-05, spelled out so
# this block runs on its own.
RD = "https://vincentarelbundock.github.io/Rdatasets/csv/"
rot = pd.read_csv(RD + "survival/rotterdam.csv")
rot["rfs_time"] = rot[["rtime", "dtime"]].min(axis=1)
rot["rfs_event"] = ((rot["recur"] == 1) | (rot["death"] == 1)).astype(int)
rot["size_mm"] = rot["size"].map({"<=20": 15, "20-50": 35, ">50": 60})
rot["grade3"] = (rot["grade"] >= 3).astype(int)
dev = rot
# No Python equivalent of rms::nomogram exists. The whole figure is this.
v = ["age", "size_mm", "nodes", "grade3"]
fit = CoxPHFitter().fit(dev[v + ["rfs_time", "rfs_event"]], "rfs_time", "rfs_event")
beta = fit.params_[v].to_numpy()
lo, hi = dev[v].min().to_numpy(), dev[v].max().to_numpy()
span = np.abs(beta * (hi - lo)) # how far each axis moves the lp
scale = 100 / span.max() # the widest axis is worth the full scale
def points(x): # per-predictor points for one patient
return beta * (np.asarray(x, float) - lo) * scale
p = points([55, 35, 4, 1])
total = p.sum()
lp = total / scale + float(beta @ lo) # back to the linear predictor
# The direct route. Both are on the uncentred scale here, so they agree.
lp_direct = float(beta @ np.array([55, 35, 4, 1]))
print(p, total, lp, lp_direct)
# Drawing it: each axis is one line segment plus a set of ticks, so
# matplotlib's hlines and text are all the plotting this needs.There is no Python equivalent of rms::nomogram — neither lifelines nor scikit-survival draws this figure, and no third-party package fills the gap. That is not an obstacle: the content of the figure is the arithmetic below, and drawing it is a matter of placing line segments with matplotlib. Note that lifelines centres its log partial hazard the way rms does, so comparisons have to be made against the same origin.
Four questions for a paper with a nomogram
- Is there an external validation? A nomogram drawn only on the development cohort is an unvalidated model, nicely typeset.
- Is there a calibration curve? The figure hands the reader an absolute risk, and whether an absolute risk is right is a calibration question, not a C-index question.
- What are the points used for? If the paper cuts them into high, intermediate and low risk groups, where did the cut-points come from and has anyone reproduced them elsewhere? Points carry no meaning across studies.
- What does it buy over the staging system already in use? That is a question for the decision curve or net reclassification, not for how the figure looks.
Common misuses
| Misuse | Why it is wrong |
|---|---|
| Treating the nomogram as a model or a method | It is a re-typesetting of an existing model; its information content is one number, the linear predictor |
| Writing that the nomogram improved predictive performance | A change of units moves nobody’s ranking and nobody’s predicted risk |
| Publishing the figure without the coefficients and baseline survival | Without those two, nobody can reproduce the model on their own data |
| Comparing points against the points in another paper | Each exchange rate is set by the widest predictor in its own dataset; the two scales are different rulers |
| Claiming the predictor worth the most points is the most important one | That reflects the range of that variable in that dataset, not its effect size or its clinical weight |
| Declaring clinical usability on the strength of a development-cohort figure | What is missing is external validation and calibration, and no amount of drawing supplies it |
| Measuring distances along the risk axis as if it were linear | Total points to risk is a non-linear transformation; the high-risk end is compressed |
| Laying a ruler diagonally across the axes | That is how a Fagan nomogram is read; here the axes are read vertically and summed |
| Drawing with rms::cph while reporting survival::coxph, without checking | Their default convergence tolerances differ, so the figure and the text drift onto slightly different models |
Reproducing every number on this page
/opt/homebrew/bin/Rscript figures/scripts/B5-09-nomogram.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.
Plot every development-cohort patient's total points against that patient's own linear predictor and the result is a perfectly straight line. What does that mean?
Show the answer and why
Correct answer: 1.000000 - total points are an affine transform of the linear predictor, so this figure adds no information about any patient
An R-squared of 1.000000 would be a warning sign anywhere else, usually meaning the outcome has found its way in among the predictors; here it is the entire argument of the page - total points equal the linear predictor shifted and then multiplied by a constant, the way Celsius becomes Fahrenheit. So unusually precise asks the wrong question: the largest residual of 0.000000 is not accuracy, it is the floating-point rounding an identity leaves behind. 0.666129 is the same model's C-index in the development cohort, which is the model's ranking ability and has nothing to do with this scatter plot. A nomogram changes the units, not the amount of information, and any claim that a model became more clinically useful because a nomogram was drawn cannot survive this figure.
On this nomogram the positive-nodes axis takes the full score on its own, and the other three axes are shared out in the same proportion. What does that tell you?
Show the answer and why
Correct answer: 100.0 points means this variable moves the linear predictor further than any other across its own axis range, and the conversion rate was set from it
The conversion rate is set like this: work out how far each variable can move the linear predictor across its own axis range, give the largest mover the full score, and share the rest out in the same proportion. So 100.0 describes which coefficient times axis span is largest in this dataset, not clinical importance - change the dataset, change the set of covariates, or simply draw one axis a little wider, and the same patient's points change. 26.2 and 13.1 are the same story: they are traces of the ranges of variation in this dataset rather than weights belonging to the variables, which is why setting them beside another paper's points means nothing. What does travel between studies is the risk itself.
This page deliberately demonstrates the two routes on a patient whose five-year risk sits mid-scale in the development cohort, rather than on a patient at very high risk. Why does that choice affect what the demonstration proves?
Show the answer and why
Correct answer: It does, because the risk axis at the bottom has a flat stretch: this patient's five-year risk of 0.522 does not sit on it
Going from total points to risk passes through a non-linear transform, so the same ten points buy different amounts of risk depending on where on the axis you are standing, and where the risk is already close to its ceiling further points buy almost nothing - that is the flat stretch of the risk axis. That is what limits the demonstration: on a patient out there, the two routes can travel two clearly different linear predictors and still land on risks that look identical, which makes agreement cheap. So this patient's five-year risk of 0.522 was chosen mid-scale on purpose, and the script records the same reason. The conversion really is a fixed 39.572 points per unit of linear predictor, but that is the relationship between total points and the linear predictor; the risk axis is a further layer on top of it, and a fixed conversion does not make that layer evenly spaced. 42.795 is the total: the points axis itself is evenly spaced from end to end and measures just as accurately at either extreme, and the non-linear one is the axis at the bottom.
When the points conversion is set, the package uses each axis's tick range rather than the range actually observed in the cohort. Where do the two differ in this model?
Show the answer and why
Correct answer: The nodes axis is ticked to 35, one above the largest value actually seen, and the conversion rate is computed from the tick side
The conversion rate is computed from the tick range, not the observed range. Three of the four axes have the two coinciding here; only the nodes axis differs, ticked to 35 where the largest value actually seen is 34. One node looks negligible, but it is part of the conversion rate - which is to say how many points an axis is worth depends on where whoever drew the figure put the ticks. Tick the 90 axis ten years further and the points for age change too. That is the mechanical reason points do not travel between papers.
This page deliberately uses the very same model as the calibration page. If a nomogram really is only a redrawing, what has to hold?
Show the answer and why
Correct answer: The calibration slope in the external cohort is still 0.691, identical to what the calibration page reports - a redrawing changes no measured number
A nomogram is a re-typesetting of a Cox model that was already fitted, so it cannot move any number computed from that model: the external calibration slope is still 0.691, the same value the calibration page reports, and that is the testable version of the claim that it is the same model. 0.666 and 0.642 are the C-indices in the development and external cohorts, and neither moves because a figure was redrawn - ease of use is an interface property, while ranking and scale were both settled before anything was plotted. What does break this in practice is drawing and reporting from two different fits: the function that draws a nomogram only accepts one package's model object, most papers report coefficients from another, and the two convergence tolerances leave the coefficients agreeing to only a few decimal places.
Chapters that use this method
Sources and licences
This page is original writing