Sensitivity analysis
A request to add a sensitivity analysis is among the most common referee comments, and it asks for something more specific than most authors supply. This page runs sixteen individually defensible analyses on one dataset so the spread of estimates becomes visible, then permutes the exposure to measure what it costs to pick a setting after seeing the results.
What a request for a sensitivity analysis is actually asking for
A sensitivity analysis swaps out one analysis decision that could reasonably have gone the other way, reruns, and asks whether the conclusion turns over.
What it varies is an analysis decision. Every word there rules something out, and the three things it rules out get mixed in constantly:
| What it varies | What that is called | Which page |
|---|---|---|
| Analysis decisions (model specification, covariates, outcome definition, inclusion rules) | Sensitivity analysis | This page |
| Data points (which observation, which study is driving the result) | Influence analysis | Influence diagnostics |
| Populations (in whom the effect is larger) | Subgroup analysis | Multiplicity |
| Significance thresholds (how to judge after many tests) | Multiplicity adjustment | Multiplicity |
The last row is the one most often confused, so to be explicit: a sensitivity analysis does not need a multiplicity adjustment. It does not ask whether one of these is real; it asks whether the same conclusion survives a change of setting. Adjusting it answers a question about significance when the question was about stability.
The dataset, and what it cannot be used to say
This page uses survival::rotterdam, 2,982 records from a breast
cancer registry, of whom 339 received hormonal therapy. The dataset has no missing
values at all (0 cells), so the very common sensitivity axis of swapping one
missing-data method for another does not exist here; the four axes below are something else.
Sixteen defensible analyses
Four axes, two options each, every combination:
| Axis | Option A | Option B | Why both are defensible |
|---|---|---|---|
| Outcome definition | Recurrence-free survival | Overall survival | Both are standard endpoints in the breast cancer literature |
| Covariate set | Age, tumour size, grade, node count | Plus menopausal status and hormone receptor | Both are done |
| Node count coding | As recorded | Capped at ten | The right tail is long and capping is a common treatment |
| Follow-up length | Complete | Truncated at five years | Five years is the conventional window in breast cancer |
The grid is a full factorial rather than a hand-picked string of settings, because only a factorial design lets the spread be attributed to an axis. A string of settings yields a scatter of points, and which decision is driving the result is the question worth asking.
figures/scripts/B8-07-sensitivity-analysis.R| Rank | Outcome | Covariates | Nodes | Follow-up | Hazard ratio | 95% CI | p |
|---|---|---|---|---|---|---|---|
| 1 | recurrence-free survival | core plus two | capped at ten | truncated at five years | 0.695 | 0.585–0.826 | < 0.001 |
| 2 | recurrence-free survival | core | capped at ten | truncated at five years | 0.717 | 0.604–0.851 | < 0.001 |
| 3 | recurrence-free survival | core plus two | capped at ten | complete | 0.733 | 0.627–0.856 | < 0.001 |
| 4 | recurrence-free survival | core | capped at ten | complete | 0.743 | 0.636–0.867 | < 0.001 |
| 5 | overall survival | core plus two | capped at ten | truncated at five years | 0.757 | 0.613–0.934 | = 0.009 |
| 6 | overall survival | core plus two | capped at ten | complete | 0.786 | 0.659–0.937 | = 0.007 |
| 7 | overall survival | core | capped at ten | complete | 0.809 | 0.680–0.964 | = 0.018 |
| 8 | overall survival | core | capped at ten | truncated at five years | 0.823 | 0.669–1.013 | = 0.066 |
| 9 | recurrence-free survival | core plus two | as recorded | truncated at five years | 0.847 | 0.715–1.002 | = 0.053 |
| 10 | recurrence-free survival | core | as recorded | truncated at five years | 0.870 | 0.736–1.028 | = 0.103 |
| 11 | recurrence-free survival | core plus two | as recorded | complete | 0.874 | 0.751–1.018 | = 0.083 |
| 12 (main) | recurrence-free survival | core | as recorded | complete | 0.884 | 0.760–1.028 | = 0.111 |
| 13 | overall survival | core plus two | as recorded | truncated at five years | 0.925 | 0.753–1.138 | = 0.462 |
| 14 | overall survival | core plus two | as recorded | complete | 0.932 | 0.784–1.108 | = 0.423 |
| 15 | overall survival | core | as recorded | complete | 0.954 | 0.803–1.133 | = 0.590 |
| 16 | overall survival | core | as recorded | truncated at five years | 0.995 | 0.811–1.220 | = 0.960 |
The table in one sentence: this dataset gives no single answer to whether there is an association. The analysis setting gives the answer.
The axis that drives the curve is the one no methods section mentions
Averaging each axis over the other three and taking the difference gives how far that axis moves the estimate on the log scale:
| Axis | Swing |
|---|---|
| Node count coding | 0.184 |
| Outcome definition | 0.093 |
| Covariate set | 0.037 |
| Follow-up length | 0.015 |
The dominant axis is whether node count is capped. The lower panel of the figure shows it directly: the dark dots in that row sit almost entirely in the left half.
This is worth pausing on. A methods section states the endpoint and lists the covariates — the two axes a reader can see, ranking second and third here. First place goes to a data-preparation decision that never appears at all.
The main analysis sits mid-curve, and that is the point
The pre-specified main analysis takes the least-processed option on each of the four axes: recurrence-free survival, core covariates, node count as recorded, follow-up complete.
Its hazard ratio is 0.884 (95% CI 0.760–1.028, p = 0.111), not statistically significant, at rank 12 of sixteen. On the same curve, 7 settings are significant.
The main analysis is neither the most conservative nor the most significant setting. Its standing does not come from where it sits on the curve; it comes from having been fixed before the curve existed. Were standing to come from position, then:
- picking the most significant setting could be written up as the specification with the clearest signal
- picking the most conservative one could be written up as the cautious approach
Both sentences are available, and both would be written after seeing the results. Picking a conservative setting after the fact and calling it caution is the same act as picking a significant one.
A robust result is an empty phrase
The sentence appears in papers constantly and carries no information on its own: it does not say which perturbations were run, what each one estimated, or whether any of them changed the conclusion.
Three things belong in the report:
- Which perturbations were run, listed individually, including the ones that changed nothing
- The effect estimate and interval under each, not just a p value, not a balance table, and certainly not a single sentence
- Whether any of them turned the conclusion over, and if so which, and why the main analysis is still the main analysis
Robust does not mean correct
The curve above is what a result that is not robust looks like. The opposite case deserves more caution: sixteen settings all reaching significance would still not establish that the conclusion is correct.
The reason is visible in this page’s own data. Confounding by indication is present in all sixteen settings, and swapping the outcome definition, the covariates, the coding, or the time window does not touch it. A tidy, uniform curve can be tilted as a whole in one direction.
Influence diagnostics makes the same point for meta-analysis: a pooled analysis can be perfectly robust to dropping any single study while every included study shares one systematic bias — because that bias does not disappear when one study leaves.
A sensitivity analysis rules out only what it actually perturbed. About the assumptions it left alone, it has said nothing.
What picking the most significant setting costs, measured
The claim above is that picking afterwards and fixing in advance are different things. How different can be measured.
The method: shuffle the exposure column, which makes the true association exactly zero, and rerun the same 16 settings. Repeat 4,000 times (seed 20260826), recording two numbers each time — the smallest p value, and the p value of the pre-specified setting.
figures/scripts/B8-07-sensitivity-analysis.R- Reporting only the most significant setting: 17.9% of runs claim significance
- Reporting only the pre-specified setting: 6.5%
The gap is 11.4 percentage points. It needs no inference to read, because the exposure was shuffled, so every significant result above is a false positive.
Same settings, same dataset, same true association of zero. The only thing that changes is which result gets written up. That is why pre-specification is not an administrative formality.
What to look for when reading a paper
- Where the sensitivity analyses live. A table in the appendix listing an effect estimate per setting and a sentence in the discussion saying the result was robust are entirely different things.
- Whether there are effect estimates, or only p values and balance tables. With only p values, you cannot see whether the conclusion would turn over.
- Whether the main and sensitivity roles were fixed in writing. Look for pre-specified, or according to the protocol, and for a registration number.
- Whether the listed perturbations are only the ones that changed nothing. On paper, a complete set and a filtered set are indistinguishable.
- What was not perturbed. This usually matters more than what was — see the section above.
Run it yourself
library(survival)
d0 <- rotterdam
d0$rfs_time <- pmin(d0$rtime, d0$dtime)
d0$rfs <- as.integer(d0$recur == 1 | d0$death == 1)
d0$nodes_cap <- pmin(d0$nodes, 10)
d0$lpgr <- log(d0$pgr + 1)
grid <- expand.grid(
outcome = c("rfs", "os"),
covars = c("core", "plus"),
nodesf = c("raw", "cap"),
followup = c("full", "trunc5"),
stringsAsFactors = FALSE
)
fit_one <- function(d, g) {
if (g$followup == "trunc5") {
tt <- 5 * 365.25
d$rfs <- ifelse(d$rfs_time > tt, 0L, d$rfs); d$rfs_time <- pmin(d$rfs_time, tt)
d$death <- ifelse(d$dtime > tt, 0L, d$death); d$dtime <- pmin(d$dtime, tt)
}
nod <- if (g$nodesf == "raw") "nodes" else "nodes_cap"
cov <- if (g$covars == "core") c("age", "size", "grade", nod)
else c("age", "size", "grade", nod, "meno", "lpgr")
lhs <- if (g$outcome == "rfs") "Surv(rfs_time, rfs)" else "Surv(dtime, death)"
f <- as.formula(paste(lhs, "~ hormon +", paste(cov, collapse = " + ")))
summary(coxph(f, data = d))$coefficients["hormon", c("coef", "Pr(>|z|)")]
}
res <- t(sapply(seq_len(nrow(grid)), function(i) fit_one(d0, as.list(grid[i, ]))))
grid$hr <- exp(res[, 1]); grid$p <- res[, 2]
print(grid[order(grid$hr), ], row.names = FALSE)Verified on R 4.6.0. Only the sixteen-setting part runs below; the permutation needs 4,000 iterations and lives in the full script.
import itertools
import numpy as np
import pandas as pd
from lifelines import CoxPHFitter
url = "https://vincentarelbundock.github.io/Rdatasets/csv/survival/rotterdam.csv"
d0 = pd.read_csv(url)
d0["rfs_time"] = np.minimum(d0["rtime"], d0["dtime"])
d0["rfs"] = ((d0["recur"] == 1) | (d0["death"] == 1)).astype(int)
d0["nodes_cap"] = d0["nodes"].clip(upper=10)
d0["lpgr"] = np.log(d0["pgr"] + 1)
size = pd.get_dummies(d0["size"], prefix="size", drop_first=True).astype(float)
d0 = pd.concat([d0, size], axis=1)
size_cols = list(size.columns)
def fit_one(outcome, covars, nodesf, followup):
d = d0.copy()
if followup == "trunc5":
tt = 5 * 365.25
d["rfs"] = np.where(d["rfs_time"] > tt, 0, d["rfs"])
d["rfs_time"] = d["rfs_time"].clip(upper=tt)
d["death"] = np.where(d["dtime"] > tt, 0, d["death"])
d["dtime"] = d["dtime"].clip(upper=tt)
nod = "nodes" if nodesf == "raw" else "nodes_cap"
cov = ["age", "grade", nod] + size_cols
if covars == "plus":
cov += ["meno", "lpgr"]
t, e = ("rfs_time", "rfs") if outcome == "rfs" else ("dtime", "death")
cph = CoxPHFitter().fit(d[[t, e, "hormon"] + cov], duration_col=t, event_col=e)
return float(np.exp(cph.params_["hormon"])), float(cph.summary.loc["hormon", "p"])
rows = []
for g in itertools.product(["rfs", "os"], ["core", "plus"], ["raw", "cap"],
["full", "trunc5"]):
hr, p = fit_one(*g)
rows.append((*g, hr, p))
out = pd.DataFrame(rows, columns=["outcome", "covars", "nodes", "followup", "hr", "p"])
print(out.sort_values("hr").to_string(index=False))The lifelines CoxPHFitter needs factors expanded into dummies by hand; the rest lines up with R.
Common misuses
| Misuse | Why it is wrong |
|---|---|
| Writing only that the sensitivity analyses showed the result was robust | Without which perturbations, what each estimated, and whether any turned over, the sentence says nothing |
| Reporting only p values for the sensitivity analyses | You cannot see whether the conclusion would turn over, which is what they are for |
| Reporting propensity score sensitivity analyses as balance tables | A balance table describes the matching, not whether the estimate moved |
| Choosing which settings to run after seeing the results | That is selective reporting, and on paper it looks like pre-specification |
| Adopting the most conservative setting as the main analysis afterwards | The same act as adopting the most significant one, pointed the other way |
| Reading all sensitivity analyses passed as the conclusion being correct | It rules out only what was perturbed; a shared bias survives every setting |
| Applying a multiplicity adjustment to sensitivity analyses | They ask about stability, not about significance |
| Treating leave-one-out as the whole of sensitivity analysis | That is influence analysis, varying data points rather than analysis decisions |
| Leaving the main and sensitivity roles unfixed in the manuscript | A reader cannot tell which one was decided in advance |
| Perturbing only the axes that leave the conclusion intact | A filtered set is indistinguishable from a complete one |
Rerun every number on this page
/opt/homebrew/bin/Rscript figures/scripts/B8-07-sensitivity-analysis.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.
One dataset, one question, sixteen individually defensible analyses. What is the hazard ratio at the end nearest the null, and what is that number for?
Show the answer and why
Correct answer: 0.995, while the other end sits clearly further from the null. Both ends are defensible analyses, so the quantity to read is the width itself, not one point chosen from inside it
0.995 is the setting closest to the null, and the other end is 0.695 — one dataset, one question, and a defensible reason for every setting in between. What this question trains is reading the whole curve as a quantity: its width says how sensitive the conclusion is to analysis decisions, and the fact that it crosses the null says that whether there is an association here is settled by the setting rather than by the data. 0.884 is the pre-specified main analysis, which sits mid-curve rather than at the right-hand end. 0.695 is the opposite end, and calling it the end nearest the null reverses the direction of the entire curve; its p value is indeed the smallest, but that follows from it being furthest from the null, not nearest to it.
Each of the four axes moves the estimate by some amount (swing on the log scale). Which axis drives the curve?
Show the answer and why
Correct answer: 0.184, the coding of node count. As recorded or capped at ten is a decision almost no paper spells out in its methods section
The node-coding swing is 0.184, roughly twice the second-placed axis, and there is a mechanism behind it: node count is the strongest prognostic factor in this dataset and is also related to who received treatment (worse prognosis, more likely treated), so how it enters the model decides how much of that confounding is removed — capping swaps the functional form of a strong confounder. The outcome definition comes second at 0.093, and the reasoning that swapping endpoints must be the largest available change does not hold here: recurrence-free and overall survival are highly correlated in breast cancer, so swapping them changes the event set while leaving the covariate structure alone. The covariate set is third at 0.037, and having the most room to move the estimate is precisely backwards — adding two variables only weakly related to treatment removes little further confounding. The lesson is that a methods section shows the endpoint and the covariate list while the spread is driven by the data-preparation decision nobody thinks needs explaining.
Where does the pre-specified main analysis fall among the sixteen settings ranked by hazard ratio, and what does that position mean?
Show the answer and why
Correct answer: Rank 12, mid-curve. It is neither the most conservative nor the most significant setting, and its standing rests entirely on having been fixed in advance
The main analysis lands at rank 12, mid-curve and slightly to the right. It does not reach statistical significance, while seven settings on the same curve do — and if the main analysis drew its standing from its position, this page would have no grounds for refusing those seven. Rank 16 is the end nearest the null, and equating the main analysis with the most conservative setting licenses an author to pick a conservative one after the fact and call it caution, which is the same act as picking the most significant one. Rank 1 is the end furthest from the null, and the claim that a main analysis should carry the clearest signal is the standard phrasing of selective reporting. Pre-specification has force precisely because it happens before this curve exists.
Shuffle the exposure so the true association is exactly zero, then rerun the same sixteen settings. If only the most significant one is reported, what proportion of runs claims significance?
Show the answer and why
Correct answer: 0.179. The exposure has been shuffled, so every significant result inside that proportion is a false positive
0.179 is the false positive proportion when only the most significant of the sixteen settings is reported. It needs no inference to read, because the exposure was shuffled: the null is built in rather than assumed. 0.065 is the pre-specified arm from the same simulation, and calling it the cherry-picked rate erases the gap between the two, which is the only thing this figure exists to show. 0.114 is the difference between them, a gap rather than a rate.
The permutation ran four thousand times. What is the Monte Carlo standard error of the false positive proportions it reports, and what does that number limit?
Show the answer and why
Correct answer: 0.0034. Differences finer than this magnitude are beyond what the simulation can resolve, so what it supports is the large gap between the two arms, not the decimals separating either bar from the nominal level
0.0034 is the Monte Carlo standard error near the nominal level, which is this simulation's resolution. It decides which differences may be read: the gap between the arms is 0.1140, more than thirty times it and entirely solid, while a few thousandths between a bar and the nominal level deserve far more caution. 0.0653 is the observed pre-specified proportion, and calling it the standard error would make the resolution as large as the signal, leaving the figure unable to show anything at all. 0.1140 is the gap between the arms, and calling it the standard error does the opposite — it condemns the one solid finding here as noise. Whether enough runs were done is settled by this number, not by intuition.
How many of the sixteen settings reach statistical significance, and what can and cannot be concluded from that count?
Show the answer and why
Correct answer: 7. The conclusion is not robust to changing an analysis decision; but in the other direction, even sixteen out of sixteen would not establish that it is correct
Seven settings reach significance and nine do not, so this conclusion is not robust to a change of analysis decision. The half worth remembering runs the other way: robust does not mean correct. Treatment in this dataset was given by indication, that confounding is present in all sixteen settings, and changing a setting cannot remove it — a curve where every point is significant can still be tilted as a whole in one direction. Reading uniform significance as a passed check is the misreading this page exists to block. 12 is the main analysis's rank on the curve rather than a count of significant settings; and even were it the count, reporting the significant settings because the main analysis understated the effect is the definition of selective reporting.
Chapters that use this method
Sources and licences
This page is original writing