People with diabetes, including type 2 diabetes (T2D), develop coronary artery disease (CAD) at about twice the rate of people without diabetes (Emerging Risk Factors Collaboration 2010). Some of that risk is inherited. A polygenic risk score (PRS) sums many small genetic effects into one number per person. A research team has three scores for every participant of a biobank:
| Name in the text | Built from a GWAS of | Column of the data | Name in the output |
|---|---|---|---|
| T2D score | T2D in the general population | t2d_score |
onset |
| CAD score | CAD in the general population | cad_score |
outcome |
| Progression score | Time from T2D to CAD, among people with T2D | t2d_to_cad_score |
progression |
The team asks one question:
Among people who already have T2D, does the progression score tell us who will develop CAD, beyond what the T2D score and the CAD score already tell us?
Wand et al. (2021) call this kind of study disease prognosis: the risk of a further outcome after a diagnosis. prsladder is built for it.
prsladder starts after the scores exist. It does not do the genotype quality control or build the scores; see Choi et al. (2020) for those steps.
This tutorial writes the analysis plan first, and then runs the full analysis in twelve steps:
It ends with a checklist for the report.
The code below loads the package. It also sets the ggplot2 theme of every figure in this tutorial.
prsladder works for any first diagnosis and any later event, and it has general names for them. Throughout the package, the first diagnosis (here T2D) is called T1 and the later event (here CAD) is called T2.
| Term of the package | Meaning in this tutorial | Where it appears |
|---|---|---|
| T1 | The T2D diagnosis | age_t1, t1_status,
t1_duration, the designs "t1" and
"t1_entry", and the axis “Time since T1” |
| T2 | CAD | The help pages and the messages of the package |
| Competing event | Death before CAD | competing_risk, n_competing, and
event = "competing" |
| Incident person | A person who joined the biobank before the T2D diagnosis | t1_status |
| Prevalent person | A person who joined the biobank after the T2D diagnosis | t1_status |
| Entry | The start of the follow-up of a person. In Step 1, it is the diagnosis for an incident person and recruitment for a prevalent person | start, and “since entry” in the output and on the
axes |
| Horizon | A number of years after entry, for a predicted risk | horizon and horizons |
The package counts time on two clocks.
| Clock | Starts at | Used by |
|---|---|---|
| Years since the diagnosis | The T2D diagnosis | times of cumulative_risk() and of
standardized_risk() |
| Years since entry | The entry of each person | horizon of predict_risk(), and
horizons of horizon_support() and of every
assess_*() function |
The two clocks agree for an incident person. For a prevalent person who joined 3 years after the diagnosis, the risk within 10 years of entry is the risk from year 3 to year 13 after the diagnosis, for a person who is alive and free of CAD at year 3. For a prevalent person, a risk from the diagnosis and a risk from entry are then two different quantities. The figure of a function has the clock of that function.
Real biobank data cannot ship with a package, so the tutorial uses a
simulated biobank. simulate_prs_data() creates one row per
person with T2D. Because the true effects of the scores are set by us,
we can check that the package recovers them. We give the three scores
hazard ratios for CAD of 1.10, 1.25, and 1.30 per standard deviation
(SD). The three scores are correlated, as real scores for related
diseases are.
Each check against the true values starts with the words “Simulation check (not possible with real data)”.
Because many people with T2D die before they develop CAD, the simulated biobank has deaths. The T2D score also raises the rate of death, with a hazard ratio of 1.2 per SD. The other two scores have no effect on death.
Age and sex change both rates, as they do in real data. Each year of age at the T2D diagnosis raises the rate of CAD by 4% and the rate of death by 8%. Women have 0.7 times the rate of men, for CAD and for death. The principal components and the birth year have no effect.
The simulator sets the level of each rate for a reference person. This person is a man with a T2D diagnosis at age 55 and average scores. Without deaths, this man would have a 15% risk of CAD within ten years of the diagnosis. Without CAD, he would have a 20% risk of death within ten years. These values were chosen for illustration and are not estimates from any study.
simulated <- simulate_prs_data(
n = 3000L,
seed = 2026L,
hazard_ratios = c(t2d = 1.10, t2d_to_cad = 1.25, cad = 1.30),
score_correlation = 0.3,
baseline_risk = 0.15,
incident_probability = 0.3,
competing_risk = 0.2,
competing_hazard_ratios = c(t2d = 1.2),
covariate_hazard_ratios = c(age_at_diagnosis = 1.04, sex = 0.7),
competing_covariate_hazard_ratios = c(age_at_diagnosis = 1.08, sex = 0.7)
)As in most biobanks, many people had T2D before they joined.
incident_probability = 0.3 makes 30% of the participants
join before their T2D diagnosis (incident), and 70% join after it
(prevalent). With the defaults of the simulator, the biobank recruits
from 2006 to 2010, and follow-up ends for every person at the end of the
study, in 2022.
The simulator follows every person from the T2D diagnosis. A real
biobank cannot: it recruits only persons who are alive, and their
earlier records come from registers. Some simulated prevalent persons
died before they would have joined, and the column
recruited is FALSE for them. We keep the
recruited persons, so that the data are those of a real biobank. Persons
whose CAD came before recruitment are recruited, because the simulator
ends follow-up at CAD and so has no deaths after CAD.
# Keep the persons who were alive at recruitment.
biobank <- simulated[simulated$recruited, ]
sum(!simulated$recruited)
#> [1] 177
print(
head(biobank[c("IID", "t2d_score", "t2d_to_cad_score", "cad_score", "sex",
"age_at_diagnosis", "age_at_exit", "exit_status",
"age_at_recruitment")], 3),
digits = 3
)
#> IID t2d_score t2d_to_cad_score cad_score sex age_at_diagnosis
#> 1 S0001 0.492 -0.720 0.147 Female 61.5
#> 2 S0002 -0.889 0.214 -0.439 Female 68.8
#> 3 S0003 0.222 1.584 1.451 Male 46.7
#> age_at_exit exit_status age_at_recruitment
#> 1 75.7 2 64.8
#> 2 74.2 0 61.6
#> 3 66.2 0 52.4
table(biobank$exit_status)
#>
#> 0 1 2
#> 1667 550 606The columns that matter are:
| Column | Meaning |
|---|---|
IID |
Participant identifier |
t2d_score, t2d_to_cad_score,
cad_score |
The three raw scores |
sex, PC1 to PC5,
birth_year |
Covariates; sex is Male or
Female, and the PCs are genetic principal components of
ancestry |
age_at_diagnosis |
Age at the T2D diagnosis (T1) |
age_at_exit |
Age at CAD, at death, or at the end of follow-up |
exit_status |
1 if CAD was observed, 2 if the person died before CAD, 0 if follow-up ended first |
age_at_recruitment |
Age at which the person joined the biobank |
The simulated columns have the names of the arguments of
prs_cohort(), but real data can use other names: each
argument takes the name of a column.
The simulator also returns true_risk, each person’s true
risk of CAD within ten years of the T2D diagnosis, which a real data set
does not have. Because a person can die first, this risk is a cumulative
incidence (Step 2).
The analysis uses all persons of the biobank. A model always looks
better on the persons it was fitted to than on new persons. Step 8
corrects for this optimism with the bootstrap, and Step 9 checks the
models in a second biobank. A random split of the biobank into a
development part and a test part is not recommended. The split gives a
weaker model and an imprecise check (?validation). The
persons must be unrelated, as in any PRS analysis.
A choice made after looking at the results biases them, and the
bootstrap of Step 8 does not correct for it (?validation).
Write the choices down before Step 1. The choices of this tutorial
are:
| Choice | Value | Reason | Steps |
|---|---|---|---|
| Start of follow-up | Recruitment index, on the clock of years since the T2D diagnosis | A biobank holds only persons alive at recruitment | 1, 10 |
| Competing event | Death before CAD | Death ends the chance of CAD | 1, 11 |
| Primary comparisons | onset+progression vs onset, and
progression+outcome vs outcome |
The question | 4, 8, 9 |
| Secondary comparison | onset+progression+outcome vs
onset+outcome |
Progression beyond both other scores, in the model of the report | 4, 8 |
| Decision rule | Both primary tests must reject at the 5% level. The secondary test follows only when both reject | The question asks for information beyond each score. This rule needs no adjustment for multiple tests | 4 |
| Prediction model of the report | onset+progression+outcome |
It uses all three scores | 7 to 9 |
| Horizons | 5 and 10 years after entry | Clinical use, if the data support them (Step 7) | 7 to 9 |
| Risk thresholds | 10% and 20% at 10 years | Risk categories of a guideline (Step 8) | 8 |
| Range of the decision curve | 5% to 30% | Around the thresholds | 8 |
| Age term | Natural spline with 3 degrees of freedom | Default of fit_ladder() |
3 |
| Design of the hazard ratios | "t1" |
The data must not choose the design of a primary test | 3, 4 |
| Design of the absolute risks | The pick of check_design(), with its default bands |
The hazard after entry can differ between incident and prevalent persons | 6, 7 to 9 |
| Bootstrap | 200 or more replicates, fixed seed (30 here, to run fast) | Monte Carlo error (Step 8) | 8 |
prs_cohort() is the only function that reads your data
frame, apart from prediction_data() for persons without
outcome data (Step 7). You tell it which column holds what. The names
you give the scores (onset, progression,
outcome) are used in every later output.
cohort <- prs_cohort(
biobank,
id = "IID",
status = "exit_status",
age_at_diagnosis = "age_at_diagnosis",
age_at_exit = "age_at_exit",
scores = c(
onset = "t2d_score",
progression = "t2d_to_cad_score",
outcome = "cad_score"
),
covariates = c("sex", "PC1", "PC2", "PC3", "PC4", "PC5", "birth_year"),
index = "recruitment",
age_at_recruitment = "age_at_recruitment",
status_codes = list(censored = 0, event = 1, competing = 2),
event_labels = c(event = "CAD", competing = "Death")
)
cohort
#> <prs_cohort> recruitment index
#> Persons: 2711 Events: 438 Competing events: 606
#> Prevalent persons: keep
#> Scores:
#> name column sign
#> onset t2d_score 1
#> progression t2d_to_cad_score 1
#> outcome cad_score 1
#> Covariates: sex, PC1, PC2, PC3, PC4, PC5, birth_year
#> Excluded: historical = 112prs_cohort() does three things:
cohort$n_excluded).index = "recruitment" sets when each person enters
follow-up. The clock of the cohort is years since the T2D diagnosis.
Incident persons enter at the diagnosis. Prevalent persons enter at
recruitment, after the years they have already lived with T2D.
Statisticians call this delayed entry, or left truncation (Putter et
al. 2007). A prevalent person is in the biobank only because the person
was alive and free of CAD at recruitment, and delayed entry accounts for
this selection. Prevalent persons whose CAD came before recruitment
(historical cases) are removed. Under either index, every person must
also be free of CAD at the T2D diagnosis, because the question is the
risk of CAD after T2D. prs_cohort() removes a person whose
CAD came at or before the diagnosis, and counts the person under
prior_event. Step 10 shows what goes wrong when every
person enters at the diagnosis instead.
status_codes tells the package what each code of
exit_status means. A death ends follow-up, but unlike the
end of a study, it also ends the chance of CAD. Death is therefore a
competing event, not censoring. Step 11 shows what goes wrong when
deaths are treated as censoring.
event_labels names the two events in the figures and
tables. Without it, they call the events T2 and the competing event. The
labels change no estimate.
The mean and SD used for standardizing are kept, because the persons of another biobank must be standardized with the same values (Step 9):
cohort$scores
#> name column sign mean sd
#> 1 onset t2d_score 1 -0.022290906 0.9832644
#> 2 progression t2d_to_cad_score 1 -0.013592420 0.9866685
#> 3 outcome cad_score 1 -0.003607009 1.0055165The sign column matters when a score was built so that a
higher value means lower risk. Pass signs = c(outcome = -1)
to flip it. Choose signs before looking at the outcome, for example from
how the score’s alleles were aligned. A sign chosen from the results is
a form of overfitting.
Delayed entry needs a condition. The times of entry and of censoring
must be independent of the times of CAD and death (Geskus 2011). Part of
the condition can be tested. check_entry() tests whether
the entry time is quasi-independent of the exit time, the first of CAD
and death. The test is the conditional Kendall’s tau of Tsai (1990).
entry_test <- check_entry(cohort)
entry_test
#> n n_exits n_pairs tau z p_value
#> 1 2711 1044 1575897 0.02326992 1.272315 0.2032612Tau is near zero, and the p-value is 0.20. The test gives no evidence
that entry and exit are dependent. A large p-value does not show that
the condition holds. The test has low power against a dependence that is
not monotone (Chiou et al. 2018). The test is also marginal. It can
reject when a covariate, such as the age at the T2D diagnosis, changes
both the entry and the rate of CAD. The Cox models of Step 3 need the
condition only given their terms, and Step 6 tests the entry in a model
with these terms. The estimates of cumulative_risk() need
the condition within each score group.
A selection that depends on the future breaks the condition, and delayed entry cannot correct it. The article Delayed entry in depth shows such a selection.
The test cannot show whether the persons of the biobank are like the population. For example, UK Biobank participants have lower mortality than the general population of the same age (Fry et al. 2017). This selection changes the absolute risks, and only data from the population can measure it.
summary() gives the numbers that a report needs:
persons, CAD events, deaths, median follow-up, and the distribution of
each raw score, overall and by status (0 for no event, 1 for CAD, and 2
for death).
summary(cohort)
#> Cohort, recruitment index
#>
#> Persons, events, and median follow-up since entry:
#> group n n_events n_competing median_followup
#> all 2711 438 606 13.242408
#> incident 863 103 152 9.684496
#> prevalent 1848 335 454 13.963115
#>
#> Raw scores:
#> name mean sd q25 median q75
#> onset -0.022291 0.9833 -0.7038 -0.011270 0.6385
#> progression -0.013592 0.9867 -0.6684 -0.006664 0.6504
#> outcome -0.003607 1.0055 -0.6869 -0.014481 0.6601
#>
#> Raw scores by status of the second event:
#> name status n mean sd
#> onset 0 1667 -0.114312 0.9779
#> onset 1 438 0.146540 0.9947
#> onset 2 606 0.108816 0.9599
#> progression 0 1667 -0.069970 0.9798
#> progression 1 438 0.250548 0.9605
#> progression 2 606 -0.049420 0.9949
#> outcome 0 1667 -0.084866 0.9982
#> outcome 1 438 0.298670 1.0471
#> outcome 2 606 0.001444 0.9534
#>
#> Excluded persons:
#> missing prior_event no_recruitment_age
#> 0 0 0
#> historical prevalent_dropped nonpositive_followup
#> 112 0 0The counts are also split by incident and prevalent persons.
historical counts the prevalent persons removed because
their CAD came before recruitment. prs_cohort() removed
nobody else, because the simulated data have no missing values and the
simulator gives CAD only after the T2D diagnosis. Persons who developed
CAD have higher mean scores than persons who did not. Persons who died
have a higher mean T2D score, the score that raises the rate of death.
The next steps measure how strong that difference is and whether it
holds after accounting for the other scores.
The PRS reporting standard (PRS-RS) of Wand et al. (2021) asks for
the characteristics of each sample, at least age and sex.
describe_cohort() gives them as a table of the gtsummary
package, with one column per status, and needs that package.
gtsummary::add_overall() adds a column for all persons. The
scores are on their raw scale, as they came from the scoring
software.
labels <- list(sex ~ "Sex", birth_year ~ "Year of birth")
year_digits <- birth_year ~
function(x) gtsummary::style_number(x, big.mark = "")
describe_cohort(cohort, label = labels, digits = year_digits) |>
gtsummary::add_overall()
#> Found litedown! Enabling r-universe template| Characteristic | Overall N = 2,7111 |
Neither event N = 1,6671 |
CAD N = 4381 |
Death N = 6061 |
|---|---|---|---|---|
| Age at T1 | 55 (9) | 52 (8) | 56 (8) | 59 (8) |
| Prevalent at recruitment | 1,848 (68%) | 1,059 (64%) | 335 (76%) | 454 (75%) |
| onset | -0.02 (0.98) | -0.11 (0.98) | 0.15 (0.99) | 0.11 (0.96) |
| progression | -0.01 (0.99) | -0.07 (0.98) | 0.25 (0.96) | -0.05 (0.99) |
| outcome | 0.00 (1.01) | -0.08 (1.00) | 0.30 (1.05) | 0.00 (0.95) |
| Sex | ||||
| Male | 1,343 (50%) | 764 (46%) | 237 (54%) | 342 (56%) |
| Female | 1,368 (50%) | 903 (54%) | 201 (46%) | 264 (44%) |
| PC1 | 0.02 (1.03) | 0.02 (1.04) | -0.02 (1.00) | 0.04 (1.05) |
| PC2 | 0.00 (1.01) | 0.00 (1.02) | -0.07 (0.94) | 0.04 (1.03) |
| PC3 | -0.01 (0.98) | -0.02 (0.96) | -0.01 (1.02) | 0.00 (1.00) |
| PC4 | 0.02 (1.00) | 0.01 (1.00) | 0.00 (1.04) | 0.06 (1.00) |
| PC5 | -0.01 (1.00) | -0.01 (1.00) | 0.02 (1.04) | -0.04 (0.96) |
| Year of birth | 1952 (10) | 1955 (9) | 1949 (9) | 1947 (9) |
| 1 Mean (SD); n (%) | ||||
Without digits, the year would print with a thousands
separator. In a terminal, print the table with
gtsummary::as_kable(). The table does not hide small cells,
which can identify persons in biobank data, and the table object holds
the data of every person: export the rendered table, never the R
object.
How fast does CAD follow T2D in the whole cohort?
cumulative_risk() with score = NULL estimates
the cumulative incidence of CAD. This incidence is the proportion of
persons with CAD by each time since the T2D diagnosis, when death can
come first. It uses the Aalen-Johansen estimator (Aalen and Johansen
1978), with delayed entry: a prevalent person enters the estimate at
recruitment. competing_risk is the cumulative incidence of
death before CAD.
overall_risk <- cumulative_risk(cohort, score = NULL, times = c(5, 10, 15))
overall_risk
#> <prs_cumulative_risk> whole cohort, Aalen-Johansen
#> Guards: min_events = 1, min_at_risk = 50
#> group time n_at_risk n_events n_competing risk lower upper competing_risk
#> 1 5 1756 85 117 0.061 0.049 0.075 0.082
#> 1 10 1774 244 335 0.125 0.110 0.141 0.169
#> 1 15 1085 373 518 0.182 0.165 0.200 0.250
#> Restricted mean time lost in `x$time_lost`risk_lower and risk_upper (printed as
lower and upper) are the limits of a 95%
confidence interval. plot_stacked() shows how the risk
divides between the two events. The lower area is the cumulative
incidence of CAD, and the area above it is the cumulative incidence of
death.
With three scores there are eight possible models: no score, each
score alone, each pair, and all three. prsladder calls this set the
ladder. fit_ladder() fits two Cox proportional-hazards
models for every rung of the ladder. The model for CAD treats a death as
censoring, and the model for death treats CAD as censoring. These models
are the cause-specific hazard models (Putter et al. 2007). Together they
give the cumulative incidence of CAD (Step 7).
Every model also contains the covariates and a smooth (spline) term
for age at T2D diagnosis. Adjusting for principal components guards
against population structure, which can create spurious associations
(Choi et al. 2020). Under the recruitment index, keep the birth year as
well. With the age at diagnosis, the birth year sets the calendar year
of each person on the clock. The models can then account for a calendar
trend (?prs_cohort). Every model uses the same persons, so
any two models can be compared fairly. spline_df names the
variables with a spline term and gives the degrees of freedom of each.
The default is 3 for age, and spline_df = NULL gives a
straight line. A numeric covariate can also have a spline term, for
example with spline_df = c(age_t1 = 3, birth_year = 3).
ladder <- fit_ladder(cohort)
ladder
#> <prs_ladder> 8 models of 3 scores, recruitment index, t1 design, with competing events
#> model n_scores n n_events loglik aic
#> base 0 2711 438 -3133.552 6287.104
#> onset 1 2711 438 -3121.678 6265.356
#> progression 1 2711 438 -3114.111 6250.222
#> outcome 1 2711 438 -3106.294 6234.588
#> onset+progression 2 2711 438 -3109.304 6242.607
#> onset+outcome 2 2711 438 -3102.451 6228.902
#> progression+outcome 2 2711 438 -3096.907 6217.813
#> onset+progression+outcome 3 2711 438 -3095.375 6216.749
#> concordance concordance_se events_per_parameter heuristic_shrinkage ph_p_value
#> 0.6162337 0.01403476 43.80000 0.8525664 0.02031953
#> 0.6320331 0.01371023 39.81818 0.8798806 0.01898256
#> 0.6418475 0.01345075 39.81818 0.8969158 0.02219477
#> 0.6507156 0.01340799 39.81818 0.9100894 0.01575074
#> 0.6469502 0.01335119 36.50000 0.8968400 0.01473862
#> 0.6554349 0.01340875 36.50000 0.9077128 0.01172659
#> 0.6628911 0.01320114 36.50000 0.9149648 0.02220847
#> 0.6643486 0.01324871 33.69231 0.9098361 0.01174094Each row is a rung, and the columns describe its model for CAD. The most useful columns are:
concordance: Harrell’s C-index, the probability that of
two persons, the one who developed CAD first had the higher predicted
risk. 0.5 is a coin toss; 1 is perfect. Because it is measured on the
persons of the fit, it is optimistic (Step 8).events_per_parameter: CAD events per model coefficient.
fit_ladder() warns below 10, where estimates become
unreliable.heuristic_shrinkage: an estimate of how much the model
is overfitted, from its likelihood-ratio statistic (van Houwelingen and
le Cessie 1990). A value near 1 means little overfitting, and a value
near 0 or below 0 means that the fit is mostly noise.ph_p_value: the global test of the proportional-hazards
assumption, over all terms of the model. A small value means that the
hazard ratio of one or more terms can change over follow-up.Here every model has at least 33 CAD events per coefficient. Every
ph_p_value is below 0.05, with values from 0.012 to 0.022.
A global test does not show which term causes the result.
survival::cox.zph() gives the test of each term, here for
the model with all three scores:
ph <- survival::cox.zph(
ladder$models[["onset+progression+outcome"]],
transform = "km"
)
round(ph$table, 3)
#> chisq df p
#> onset 1.730 1 0.188
#> progression 0.864 1 0.353
#> outcome 0.094 1 0.759
#> sex 0.000 1 0.996
#> PC1 0.251 1 0.616
#> PC2 2.393 1 0.122
#> PC3 3.657 1 0.056
#> PC4 4.175 1 0.041
#> PC5 6.054 1 0.014
#> birth_year 0.893 1 0.345
#> natural_spline(age_t1, df = 3) 3.212 3 0.360
#> GLOBAL 27.183 13 0.012The three scores have p-values of 0.19 or more. The test gives no
evidence that their hazard ratios change over follow-up. The small
global value comes from PC4 and PC5. All eight
models have the same covariates, and their global tests then reject
together. With real data, a small p-value for a score means that its
reported hazard ratio is an average over follow-up.
plot(ph) then shows how each coefficient changes with
time.
Simulation check (not possible with real data): the simulator gives
the principal components no effect, so the rejection for
PC4 and PC5 is a chance result.
summary() gives the hazard ratio (HR) for CAD of each
score in each model, with its 95% confidence interval:
hr <- summary(ladder)
print(
hr[c("model", "term", "hr", "hr_lower", "hr_upper", "p_value")],
digits = 3
)
#> model term hr hr_lower hr_upper p_value
#> 1 onset onset 1.26 1.151 1.39 1.12e-06
#> 2 progression progression 1.35 1.228 1.48 4.79e-10
#> 3 outcome outcome 1.43 1.300 1.57 1.03e-13
#> 4 onset+progression onset 1.17 1.059 1.29 1.95e-03
#> 5 onset+progression progression 1.29 1.164 1.42 6.99e-07
#> 6 onset+outcome onset 1.15 1.042 1.27 5.55e-03
#> 7 onset+outcome outcome 1.37 1.240 1.51 4.48e-10
#> 8 progression+outcome progression 1.24 1.126 1.37 1.50e-05
#> 9 progression+outcome outcome 1.35 1.219 1.48 3.58e-09
#> 10 onset+progression+outcome onset 1.10 0.989 1.21 8.01e-02
#> 11 onset+progression+outcome progression 1.21 1.097 1.34 1.73e-04
#> 12 onset+progression+outcome outcome 1.32 1.189 1.46 1.12e-07In the model with all three scores, the HR of
progression is 1.21. It means that at any time after the
T2D diagnosis, the rate of CAD is 21% higher for a person whose
progression score is one SD above another’s, when the other terms of the
model are the same. This rate is the rate of CAD among persons who are
alive and free of CAD. A hazard ratio for this rate does not give the
same effect on the risk of CAD, because the risk also depends on the
rate of death (Putter et al. 2007). Steps 5 and 7 give the risks.
Alone, each score also carries some of the effect of the other two, because the scores are correlated. In the model with all three scores, each HR is the effect of that score when the other two are held fixed.
Simulation check (not possible with real data): in the model with all three scores, the hazard ratios are close to the true values of 1.10, 1.25, and 1.30, and each 95% interval contains its true value.
competing_coefficients holds the hazard ratios for
death, in the format of coefficients:
death_hr <- ladder$competing_coefficients
print(
death_hr[death_hr$model == "onset+progression+outcome" &
death_hr$term %in% c("onset", "progression", "outcome"),
c("term", "hr", "hr_lower", "hr_upper")],
digits = 3
)
#> term hr hr_lower hr_upper
#> 80 onset 1.248 1.144 1.36
#> 81 progression 0.929 0.853 1.01
#> 82 outcome 1.008 0.923 1.10Simulation check (not possible with real data): the simulator gives the T2D score a hazard ratio for death of 1.2, and the estimate is 1.25 (95% CI 1.14 to 1.36). The simulator gives the other two scores no effect on death, and the interval of each contains 1.
A score that raises the rate of death lowers the cumulative incidence
of CAD, because fewer persons live long enough to develop it. Latouche
et al. (2013) recommend that a report give the cause-specific hazard
ratios of both events. plot_ladder() with
event = "both" draws them, with one panel per score and one
row per model:
compare_ladder() compares two nested models, a full
model and a reduced model that lacks one or more of its scores. It uses
the likelihood-ratio test, whose null hypothesis is that the added
scores carry no information. The test compares the models for CAD. It
asks whether the scores change the rate of CAD among persons alive and
free of CAD. The test uses all persons, which is one reason not to set
persons aside for a test sample.
The plan gives two co-primary tests:
onset+progression
versus onset), andprogression+outcome
versus outcome).The question asks for information beyond each of the other scores. The answer is then yes only when both tests reject at the 5% level. Because both tests must reject, the chance of a false yes is at most 5%, and the level needs no adjustment for two tests. A plan in which one rejection is enough must test each comparison at the 2.5% level.
primary <- list(
c("onset+progression", "onset"),
c("progression+outcome", "outcome")
)
primary_tests <- compare_ladder(ladder, pairs = primary)
print(primary_tests, digits = 3)
#> full reduced added df chisq p_value delta_aic
#> 1 onset+progression onset progression 1 24.7 6.53e-07 -22.7
#> 2 progression+outcome outcome progression 1 18.8 1.47e-05 -16.8
#> delta_concordance
#> 1 0.0149
#> 2 0.0122Both p-values are below 0.05, and the rule of the plan gives a yes.
The progression score adds information beyond each of the other scores.
delta_aic below zero and delta_concordance
above zero point the same way; these two columns are descriptive and are
not tested.
The secondary comparison of the plan adds progression to a model that
already has both other scores (onset+progression+outcome
versus onset+outcome). Its full model is the prediction
model of the report. The plan allows this test, because both primary
tests rejected.
secondary <- list(c("onset+progression+outcome", "onset+outcome"))
secondary_test <- compare_ladder(ladder, pairs = secondary)
print(secondary_test, digits = 3)
#> full reduced added df chisq p_value
#> 1 onset+progression+outcome onset+outcome progression 1 14.2 0.000169
#> delta_aic delta_concordance
#> 1 -12.2 0.00891The p-value of this test is below 0.001.
Without pairs, compare_ladder() runs all 19
nested comparisons of three scores. Use them to describe the ladder, not
to search for the smallest p-value. The package does not adjust for
multiple testing.
Hazard ratios are hard to explain to a clinician. The proportion of
people who develop CAD within a given number of years is easier.
cumulative_risk() divides the persons into groups of equal
size by one score (here fifths, or quintiles) and estimates the
cumulative incidence of CAD by each time since the T2D diagnosis.
by_quintile <- cumulative_risk(
cohort,
score = "progression",
times = c(5, 10),
groups = 5L
)
print(
by_quintile$risks[c("group", "time", "n", "n_events", "risk", "difference",
"difference_lower", "difference_upper")],
digits = 2
)
#> group time n n_events risk difference difference_lower difference_upper
#> 1 1 5 543 12 0.038 NA NA NA
#> 2 1 10 543 32 0.080 NA NA NA
#> 3 2 5 542 16 0.059 0.021 -0.0155 0.057
#> 4 2 10 542 35 0.097 0.017 -0.0252 0.060
#> 5 3 5 542 15 0.052 0.014 -0.0206 0.049
#> 6 3 10 542 57 0.136 0.056 0.0119 0.100
#> 7 4 5 542 17 0.063 0.025 -0.0126 0.062
#> 8 4 10 542 57 0.143 0.063 0.0176 0.109
#> 9 5 5 542 25 0.089 0.050 0.0099 0.091
#> 10 5 10 542 63 0.165 0.085 0.0374 0.133
by_quintile$test
#> statistic df p_value
#> 1 32.7004 4 1.375634e-06risk is the Aalen-Johansen estimate of the proportion
with CAD by time years after the T2D diagnosis. At 10
years, the risk rises with the score in every step. At 5 years, the
lowest fifth has the lowest risk and the highest fifth has the highest
risk, but the risk does not rise in every step between them. Each fifth
holds only 543 persons, and few of them have CAD by 5 years.
difference is the gap to group 1, the lowest fifth of the
progression score; change the comparison group with
reference. test asks whether the groups differ
at all.
by_quintile holds the curves as well as the table.
Latouche et al. (2013) recommend that a report give the cumulative
incidence of both events. plot_risk() with
event = "both" draws the two cumulative incidences on one
scale, one panel per event, with a 95% band around each curve. The risk
table below counts the persons at risk and the events of each kind. Each
curve has a step at every event of its group. The times of
the call set where the curves end and the columns of the risk table, so
three to five times are enough. A prevalent person enters the curve at
recruitment, so the number at risk in a group can rise after the
diagnosis. An early risk set can hold few persons, and each event in it
adds a large step to every later risk. Because of this, a curve stops at
the first event time with fewer than min_at_risk persons at
risk, and a group whose first risk sets are too small has no curve. A
positive entry_lag in prs_cohort() then starts
the curves after the small risk sets.
plot_stacked() shows the same curves stacked, one panel
per group:
describe_risk() gives the risks as a table of the
gtsummary package, with one row per quintile and one column per time.
Each cell is the risk with its 95% confidence interval. It gives one
table per event, and gtsummary::tbl_stack() joins them:
gtsummary::tbl_stack(
list(
describe_risk(by_quintile),
describe_risk(by_quintile, event = "competing")
),
group_header = c("CAD", "Death")
)| Group of progression | N |
Cumulative incidence (95% CI)1
|
|
|---|---|---|---|
| 5 | 10 | ||
| CAD | |||
| 1 (lowest) | 543 | 3.8% (2.2%, 6.7%) | 8.0% (5.6%, 11%) |
| 2 | 542 | 5.9% (3.6%, 9.6%) | 9.7% (6.9%, 14%) |
| 3 | 542 | 5.2% (3.1%, 8.8%) | 14% (11%, 18%) |
| 4 | 542 | 6.3% (3.9%, 10%) | 14% (11%, 18%) |
| 5 (highest) | 542 | 8.9% (6.0%, 13%) | 16% (13%, 21%) |
| Death | |||
| 1 (lowest) | 543 | 8.3% (5.6%, 12%) | 18% (15%, 23%) |
| 2 | 542 | 10.0% (7.0%, 14%) | 19% (15%, 23%) |
| 3 | 542 | 7.3% (4.8%, 11%) | 16% (13%, 21%) |
| 4 | 542 | 6.6% (4.2%, 10%) | 14% (10%, 18%) |
| 5 (highest) | 542 | 8.9% (6.1%, 13%) | 18% (14%, 22%) |
| 1 Aalen-Johansen estimate of the cumulative incidence of CAD, by time since T1 in the units of the age columns. Pointwise 95% interval on the log scale of the risk. –: no CAD event, or fewer than 50 persons at risk at the time or at an earlier event time. | |||
The table holds no data of single persons, but it does not hide small counts either.
With ten groups, curves are hard to tell apart.
plot_gradient() shows the risk of each group at one time
instead:
The rise between neighboring deciles is uneven, because each decile holds only 272 persons.
Keep two points in mind (Choi et al. 2020). A high score raises risk;
it does not make CAD certain. And most people who develop CAD are not in
the top fifth, because the other four fifths together hold four times as
many people. Here the top fifth has 27% of the CAD cases by ten years
(case_share in by_quintile$risks).
The article Risk
by score group in depth gives three more results of
cumulative_risk():
cut_probs, andThe models of Step 3 have one clock for all persons, the years since the T2D diagnosis. At year 5, a prevalent person and an incident person then share one baseline hazard. This assumption can fail. A prevalent person was well enough to join the biobank years after the diagnosis, and lived with T2D in an earlier calendar period. An incident person is followed through the first months after the diagnosis, when the work-up can find CAD. Such differences matter most for the absolute risks, because the baseline hazard sets those risks.
fit_ladder() has three designs:
design |
Clock | Hazard after entry | Use |
|---|---|---|---|
"t1" (default) |
Years since the diagnosis | One baseline for all persons | Hazard ratios and primary tests |
"t1_entry" |
Years since the diagnosis | One baseline, a constant factor for prevalent persons, and a log-linear term for the years from the diagnosis to entry | Absolute risks, when the rule picks it |
"index" |
Years since entry | One baseline for incident persons and one for prevalent persons, and a term for the years from the diagnosis to entry | Absolute risks, when the rule picks it |
?fit_ladder gives the models and the assumption of each
design. check_design() tests the entry in the model with
all scores, and it applies a rule that the analysis plan fixed:
checks <- check_design(cohort)
checks
#> $tests
#> test null_model statistic df p_value rejected
#> 1 entry_terms t1 1.448719 2 0.4846350 FALSE
#> 2 entry_bands t1_entry 3.763538 4 0.4389523 FALSE
#> 3 index_duration index 0.661233 3 0.8822822 FALSE
#>
#> $design
#> [1] "t1_entry"The three tests ask three questions:
entry_terms: do the two terms of
"t1_entry" improve on "t1"? A rejection shows
that "t1" is not correct, but not that
"t1_entry" is adequate.entry_bands: does the factor of the prevalent persons
change with the years since entry? The default bands end at 1, 2, 4, and
7 years. Fix the bands before the analysis, because the p-value assumes
that the data did not choose them.index_duration: does the effect of the years from the
diagnosis to entry change with the years since entry? This effect must
stay constant under "index".The rule uses only entry_bands. If the test does not
reject at the 5% level, the pick is "t1_entry". If it
rejects, the pick is "index". The rule cannot pick
"t1". Here entry_bands has a p-value of 0.44,
and the pick is "t1_entry". With 438 CAD events, a result
without a rejection is weak evidence.
Simulation check (not possible with real data): the pick is the
expected one, because simulated has no effect of the entry.
simulate_prs_data() adds such an effect only when its
arguments prevalent_hazard_ratio or
duration_hazard_ratio differ from their defaults.
The last step of the rule is a new ladder with the pick:
risk_ladder <- fit_ladder(cohort, design = checks$design)
hr_risk <- summary(risk_ladder)
in_full <- function(x) x[x$model == "onset+progression+outcome", ]
data.frame(
term = in_full(hr)$term,
t1 = round(in_full(hr)$hr, 3),
pick = round(in_full(hr_risk)$hr, 3)
)
#> term t1 pick
#> 1 onset 1.096 1.094
#> 2 progression 1.214 1.214
#> 3 outcome 1.316 1.314The hazard ratios of the two ladders differ by at most 0.002 here.
The report keeps ladder for the hazard ratios and the tests
of Steps 3 and 4. The data then do not choose the design of a primary
test. The absolute risks of Steps 7 to 9 use risk_ladder.
Each result holds its design in settings$design.
loglik, aic, and concordance do
not compare designs (?fit_ladder).
?check_design gives the limits of the rule. The pick
comes from the CAD model, and the death model uses the same design. The
rule has not been studied with competing events. A study that pools
several biobanks fixes one design for all of them, because the pick
varies by chance.
The article Delayed
entry in depth shows the rule on data in which recruitment changes
the hazard of CAD. In those data, entry_bands rejects, and
the pick is "index".
A prediction horizon counts years after entry: after the diagnosis
for an incident person, and after recruitment for a prevalent person. A
horizon can only be evaluated if enough persons are followed that long
after entry. horizon_support() checks each horizon for at
least 10 events, 50 persons still followed, and enough uncensored
follow-up.
horizon_support(cohort, horizons = c(5, 10, 15, 20))
#> horizon n n_events n_at_risk censoring_survival supported
#> 1 5 2711 181 2254 0.972117588 TRUE
#> 2 10 2711 345 1531 0.458958669 TRUE
#> 3 15 2711 434 253 0.006080652 FALSE
#> 4 20 2711 438 0 0.000000000 FALSEThe guard uses the smaller censoring survival of the incident and the
prevalent persons (see ?horizon_support). At 15 years, the
censoring survival of the incident persons is below 0.02, so that
horizon fails. Too few persons are followed for 20 years after entry, so
that horizon also fails. Every assess_*() function runs
this check and skips an unsupported horizon with a warning. The planned
horizons, 5 and 10 years, are supported.
predict_risk() turns a rung into each person’s
probability of CAD within a horizon after entry, here ten years. Because
each rung holds its model for death, the probability is the cumulative
incidence of CAD. For a prevalent person, it is the risk in the ten
years after recruitment, given the years already lived with T2D.
report_fit <- risk_ladder$models[["onset+progression+outcome"]]
round(predict_risk(report_fit, newdata = cohort$data[1:5, ], horizon = 10), 3)
#> [1] 0.151 0.122 0.216 0.118 0.052The persons do not need to be in the data. Suppose a 55-year-old
woman received a T2D diagnosis in 2010 and we know her raw scores. Her
diagnosis year and birth year are within the range of the biobank,
because the model uses birth_year and cannot be trusted
outside its data. We compare two versions of her, with the progression
score one SD above and one SD below the average. The principal
components are set to 0, the average of the simulated ones. We use the
column names of the development data, and prediction_data()
applies the rules of the cohort: it standardizes each raw score with the
development mean, SD, and sign, as prs_cohort() did, and
sets the entry.
persons <- data.frame(
age_at_diagnosis = 55,
sex = "Female",
PC1 = 0, PC2 = 0, PC3 = 0, PC4 = 0, PC5 = 0,
birth_year = 1955,
t2d_score = 0.2,
t2d_to_cad_score = c(-1, 1),
cad_score = 0.5
)
newdata <- prediction_data(persons, cohort)
newdata[c("start", "age_t1", "t1_status", "t1_duration", "progression")]
#> start age_t1 t1_status t1_duration progression
#> 1 0 55 incident 0 -0.9997355
#> 2 0 55 incident 0 1.0272877
woman <- rbind(
cumulative_incidence = predict_risk(report_fit, newdata, horizon = 10),
net_risk = predict_risk(report_fit, newdata, horizon = 10, competing = FALSE),
death = predict_risk(report_fit, newdata, horizon = 10, event = "competing")
)
round(woman, 3)
#> [,1] [,2]
#> cumulative_incidence 0.096 0.141
#> net_risk 0.104 0.151
#> death 0.146 0.124start = 0 means that she enters follow-up at her
diagnosis, and t1_status and t1_duration show
that she counts as an incident person. Her ten-year risk of CAD is about
10% with a low progression score and 14% with a high one. This
cumulative incidence is the risk to tell a patient.
competing = FALSE gives the net risk, the risk in a world
where nobody dies before CAD. The net risk is higher, because it ignores
that she can die first. The last row, from
event = "competing", is her risk of death before CAD. It
uses the same two models, with their roles swapped.
Three years later she is 58 and still alive and free of CAD. Her risk
over the next ten years is now a conditional risk: the risk from year 3
to year 13 after her diagnosis, given no CAD and no death by year 3
(Benichou and Gail 1990). age_at_entry names the column
with the age from which to predict.
persons$age_now <- 58
later <- prediction_data(persons, cohort, age_at_entry = "age_now")
later[c("start", "t1_status", "t1_duration")]
#> start t1_status t1_duration
#> 1 3 prevalent 3
#> 2 3 prevalent 3prediction_data() now calls her prevalent, like a
volunteer who joined the biobank three years after the diagnosis. Under
risk_ladder, she would then get the factor of those
volunteers. She is a patient of a clinic, and the model cannot show
whether she resembles them. This choice belongs to the analyst
(?prediction_data). t1_status = "incident"
treats her as an incident person who was followed from her diagnosis to
year 3:
later <- prediction_data(
persons,
cohort,
age_at_entry = "age_now",
t1_status = "incident"
)
later[c("start", "t1_status", "t1_duration")]
#> start t1_status t1_duration
#> 1 3 incident 0
#> 2 3 incident 0
woman_later <- predict_risk(report_fit, later, horizon = 10)
round(woman_later, 3)
#> [1] 0.108 0.157start stays at 3, the time of the prediction.
t1_status and t1_duration describe her
entry.
Her risk over the next ten years is now about 11% and 16%. It is a little higher than at diagnosis, because in this simulation the rate of CAD increases with time since the T2D diagnosis.
A hazard ratio per SD does not give the size of the risk.
standardized_risk() gives the absolute risk at percentiles
of one score. For each percentile, it gives every person of the cohort
the score at that percentile and keeps the other predictors of each
person. It then averages the predicted risks of the persons (Makuch
1982).
by_percentile <- standardized_risk(
risk_ladder,
"progression",
times = 10,
probs = c(0.1, 0.5, 0.9)
)
print(
by_percentile$risks[c("prob", "raw_score", "time", "risk", "competing_risk",
"difference", "ratio")],
digits = 2
)
#> prob raw_score time risk competing_risk difference ratio
#> 1 0.1 -1.2840 10 0.097 0.21 NA NA
#> 2 0.5 -0.0067 10 0.140 0.20 0.042 1.4
#> 3 0.9 1.2459 10 0.196 0.19 0.098 2.0At the 10th percentile of the progression score, the ten-year
cumulative incidence of CAD is 10%. At the 90th percentile, it is 20%.
difference and ratio compare each percentile
with the first, here the 10th percentile. reference changes
the comparison. In Step 5, the lowest and the highest fifth have
ten-year risks of 8% and 16%. These two groups hold the persons with
scores near these percentiles, but the results differ, because the
scores are correlated. A group also holds the other scores of its
persons, and a standardized risk keeps the other scores of each person
of the cohort. As in cumulative_risk(), times
counts years since the T2D diagnosis. The average is over the persons of
the recruited cohort. Under risk_ladder, each person counts
as an incident person who starts at the diagnosis. The result is then
the risk from the diagnosis with the hazard of an incident person
(?standardized_risk).
For most prevalent persons of this cohort, this risk is an
extrapolation. An incident person has the diagnosis after recruitment
starts, and most prevalent persons here have the diagnosis before the
first diagnosis of an incident person. The standardized risk then
extrapolates to incident persons diagnosed before that time. At the
median, risk_ladder gives 14% and ladder gives
12%. Under the design "t1" of ladder, a risk
from the diagnosis does not extrapolate. The article Delayed
entry in depth compares the predictions of both ladders with the
true risks of the simulator, and it gives the cost of the extrapolation.
This cost is not a reason to use "t1" for absolute risks,
because an effect of the entry makes "t1" biased
(?fit_ladder). Give a risk from the diagnosis with an
interval, such as the bootstrap interval below. The risks from entry of
Steps 8 and 9 do not extrapolate in this way.
competing_risk is the standardized cumulative incidence
of death before CAD. It falls across the percentiles, from 20.7% to
18.8%. A higher rate of CAD lowers this incidence, because a person with
CAD is no longer at risk of death before CAD. The score is in the models
of both events. Because of this, the contrast between two percentiles
also includes any effect of the score on death.
The default model, model = "progression", has the
progression score as its only score. It adjusts for the covariates and
the age at diagnosis, but not for the other two scores. Its HR per SD
for CAD is 1.35 (Step 3). A larger model also adjusts for the other
scores, which keep their observed values. n_boot adds
bootstrap percentile intervals. As in Step 8, this tutorial uses 30
replicates to run fast, and an analysis needs 200 or more.
adjusted <- standardized_risk(
risk_ladder,
"progression",
times = 10,
model = "onset+progression+outcome",
n_boot = 30L,
seed = 2026L
)
print(
adjusted$risks[c("prob", "risk", "risk_lower", "risk_upper", "difference",
"difference_lower", "difference_upper")],
digits = 2
)
#> prob risk risk_lower risk_upper difference difference_lower difference_upper
#> 1 0.1 0.10 0.070 0.14 NA NA NA
#> 2 0.5 0.13 0.095 0.18 0.029 0.016 0.046
#> 3 0.9 0.17 0.122 0.22 0.064 0.033 0.107The contrast is smaller, because in this model the HR of the
progression score is 1.21. Each replicate fits the model again, and the
intervals include the variance of the extrapolation
(?standardized_risk). At the 90th percentile, the interval
of the risk is 12% to 22%. The same call with ladder gives
13% to 17%. This interval is narrower, because a risk from the diagnosis
does not extrapolate under the design "t1".
assess_discrimination() reports two measures at each
horizon:
A person who died before CAD and before the horizon counts as a person without CAD (Blanche et al. 2013). Both measures weight each person by the inverse probability of remaining uncensored. Without other arguments, the function evaluates the development persons:
apparent <- assess_discrimination(risk_ladder, horizons, pairs = primary)
print(
apparent$models[apparent$models$horizon == 10,
c("model", "evaluation", "auc", "auc_lower", "auc_upper",
"brier")],
digits = 3
)
#> model evaluation auc auc_lower auc_upper brier
#> 9 base apparent 0.589 0.555 0.622 0.114
#> 10 onset apparent 0.600 0.567 0.633 0.113
#> 11 progression apparent 0.626 0.593 0.658 0.112
#> 12 outcome apparent 0.638 0.605 0.670 0.112
#> 13 onset+progression apparent 0.628 0.596 0.661 0.112
#> 14 onset+outcome apparent 0.639 0.607 0.672 0.112
#> 15 progression+outcome apparent 0.654 0.622 0.686 0.111
#> 16 onset+progression+outcome apparent 0.654 0.622 0.686 0.111The column evaluation says "apparent".
These values are optimistic, because the models were fitted to the same
persons. Their intervals also treat the fitted models as fixed. Do not
report them as the performance of the models on new persons.
The bootstrap corrects the apparent values for optimism (Harrell et al. 1996). Each replicate draws a sample of the persons with replacement and fits the ladder again on it. The replicate then measures how much better the new fit does on its own sample than on all persons. The mean of this difference is the optimism, and the corrected value is the apparent value minus the optimism. The correction uses all persons, and Steyerberg et al. (2001) found it more accurate than a random split.
bootstrap_plan() holds the settings. We give the same
plan to every assess_*() function, and each function then
uses the same bootstrap samples. This tutorial uses 30 replicates to run
fast. An analysis needs 200 or more. On one computer, each of the three
bootstrap calls of this step took 13 to 19 seconds with 30 replicates.
With 200 replicates, the three calls then take about 5 minutes for this
cohort. ?bootstrap_plan gives the times for a larger
cohort.
The bootstrap plan holds only its settings, and no data.
interval = "analytic", the default, gives an interval to
the measures of one model that have a standard error from an influence
function, such as the AUC. interval = "two-stage" gives an
interval to every measure, at the cost of
n_boot * (n_inner + 1) replicates.
The correction repeats only the steps of fit_ladder().
The analysis plan fixed the pairs, thresholds, rung, and
spline_df before the analysis. A choice made from the
results makes the correction too small. The correction takes the design
as fixed, and it does not repeat the selection of
check_design().
discrimination <- assess_discrimination(
risk_ladder,
horizons,
pairs = primary,
validation = plan
)
both <- rbind(apparent$models, discrimination$models)
print(
both[both$horizon == 10 & both$model == "onset+progression+outcome",
c("model", "evaluation", "auc", "auc_lower", "auc_upper", "brier")],
digits = 3,
row.names = FALSE
)
#> model evaluation auc auc_lower auc_upper brier
#> onset+progression+outcome apparent 0.654 0.622 0.686 0.111
#> onset+progression+outcome optimism-corrected 0.632 0.600 0.664 0.112The column evaluation of the corrected result says
"optimism-corrected". optimism holds the
apparent value, the optimism, and its Monte Carlo standard error, the
part of the uncertainty that comes from the number of replicates:
optimism_auc <- discrimination$optimism
optimism_auc <- optimism_auc[
optimism_auc$measure == "auc" & optimism_auc$horizon == 10,
]
print(
optimism_auc[c("model", "apparent", "optimism", "optimism_mcse",
"n_boot_used")],
digits = 3
)
#> model apparent optimism optimism_mcse n_boot_used
#> 21 base 0.589 0.0211 0.00298 30
#> 22 onset 0.600 0.0204 0.00287 30
#> 23 progression 0.626 0.0204 0.00256 30
#> 24 outcome 0.638 0.0207 0.00301 30
#> 25 onset+progression 0.628 0.0211 0.00239 30
#> 26 onset+outcome 0.639 0.0213 0.00285 30
#> 27 progression+outcome 0.654 0.0211 0.00282 30
#> 28 onset+progression+outcome 0.654 0.0221 0.00270 30With 438 CAD events, the optimism of the model with all three scores is 0.022, against a standard error of its AUC of 0.016. The eight models have about the same optimism, from 0.020 to 0.022. Each model has the covariates and the age spline, and a score adds only one coefficient. In a smaller cohort, or with more coefficients, the optimism grows. With 30 replicates, the Monte Carlo standard error is at most 15% of the optimism. With fewer replicates, it can be as large as the optimism, which is one reason why an analysis needs more replicates.
plot_discrimination() draws the corrected values, with
one panel per horizon:
The base model has no score. It predicts from the
covariates and the age at diagnosis. At 10 years, its corrected AUC is
0.57, against 0.63 for the model with all three scores.
The pairs element gives the change in AUC and Brier
score when progression is added:
print(
discrimination$pairs[c("full", "reduced", "horizon", "delta_auc",
"delta_auc_lower", "delta_auc_upper", "delta_brier")],
digits = 3
)
#> full reduced horizon delta_auc delta_auc_lower delta_auc_upper
#> 1 onset+progression onset 5 0.0165 NA NA
#> 2 progression+outcome outcome 5 0.0124 NA NA
#> 3 onset+progression onset 10 0.0273 NA NA
#> 4 progression+outcome outcome 10 0.0163 NA NA
#> delta_brier
#> 1 -0.000197
#> 2 -0.000115
#> 3 -0.000964
#> 4 -0.000544Each rung has its own model for death, with the same scores as its
model for CAD. A pair of rungs therefore adds the score to both models.
The corrected changes have no confidence interval. The interval of the
apparent change treats the fitted models as fixed. With an added score
of no effect, it covers the true change too rarely
(?assess_discrimination). The likelihood-ratio tests of
Step 4 answer whether the progression score adds information. No
improvement in performance is the same null hypothesis as a coefficient
of zero for the added score (Pepe et al. 2013). Report
delta_auc as a size of effect, and use the likelihood-ratio
test as the test of added information. An AUC of about 0.63 means that
the scores separate groups of people, not individuals, which is typical
for a PRS.
A model can rank persons well and still predict risks that are too
high or too low. assess_calibration() compares predicted
and observed risk.
calibration <- assess_calibration(
risk_ladder,
horizons,
groups = 10L,
validation = plan
)
overall <- calibration$models
print(
overall[overall$model == "onset+progression+outcome",
c("horizon", "oe_ratio", "expected_risk", "observed_risk", "slope")],
digits = 3
)
#> horizon oe_ratio expected_risk observed_risk slope
#> 8 5 0.984 0.0669 0.0668 1.022
#> 16 10 0.986 0.1309 0.1324 0.933oe_ratio is observed divided by expected CAD events
(Crowson et al. 2016). 1 is ideal; above 1 means that the model predicts
too few events. It uses the model for CAD only.expected_risk is the mean predicted cumulative
incidence, and observed_risk is the Aalen-Johansen
estimate. Their comparison checks the cumulative incidence, which uses
the models of both events (Wolbers et al. 2009).slope is the calibration slope of the model for CAD. 1
is ideal; below 1 means that predictions are too extreme, the usual sign
of overfitting.On the development persons, the apparent O/E ratio is close to 1 by
construction, and the apparent slope over all follow-up is exactly 1.
Only the corrected values are informative here. The table holds the
corrected O/E ratio and the corrected slope. The columns
expected_risk and observed_risk keep their
apparent values (?assess_calibration).
The corrected O/E ratio is 0.98 at 5 years and 0.99 at 10 years. For new persons of the same population, the model for CAD then predicts about the observed number of events. The corrected slope is 1.02 at 5 years and 0.93 at 10 years, and the two horizons do not agree.
Because the slope at a horizon uses only the events before the
horizon, it is less precise. Its apparent value is also not exactly 1,
because the windows stop at the horizon. The corrected slope over all
follow-up, in the element shrinkage, is the better measure
of overfitting:
print(calibration$shrinkage[c("model", "slope")], digits = 3)
#> model slope
#> 1 base 0.888
#> 2 onset 0.897
#> 3 progression 0.901
#> 4 outcome 0.914
#> 5 onset+progression 0.898
#> 6 onset+outcome 0.909
#> 7 progression+outcome 0.910
#> 8 onset+progression+outcome 0.905For the model with all three scores, the corrected slope is 0.905.
New persons would need their linear predictors shrunk by this factor
toward the mean (van Houwelingen and le Cessie 1990). For the same model
of risk_ladder, heuristic_shrinkage gave 0.897
without a bootstrap. The table of Step 3 shows the value of
ladder, which has another design.
The groups element has the predicted and the observed
cumulative incidence in each tenth of predicted risk, with apparent
values. plot_calibration() draws them:
The axes give the risk within 10 years of entry, the time scale of
the predictions. Without competing events,
assess_calibration() also gives a smooth calibration curve,
with its summaries ici, e50, and
e90 (Austin et al. 2020). With competing events, the
package does not estimate this curve yet, and these columns are
NA.
Clinical guidelines act on risk categories. The 2023 ESC guidelines for people with type 2 diabetes define categories of 10-year cardiovascular disease risk (Marx et al. 2023). A risk of 10% to less than 20% is high, and a risk of 20% or more is very high. The guidelines estimate this risk with SCORE2-Diabetes (SCORE2-Diabetes Working Group 2023). This model predicts a composite of cardiovascular events, and it treats death from other causes as a competing event. Our risk is the cumulative incidence of CAD only. We use the categories only to show the functions.
A model flags a person whose predicted risk is at or above a
threshold. A flag helps a person who will develop CAD. It can harm a
person who will not, for example through a treatment that the person
does not need. Net benefit weighs the two (Vickers and Elkin 2006). A
threshold \(p_t\) sets the weight. At
\(p_t\), the harm of one false positive
is \(p_t / (1 - p_t)\) times the
benefit of one true positive. Net benefit is the number of true
positives per person, minus the number of false positives per person
times \(p_t / (1 - p_t)\). With
censored data, assess_net_benefit() weights each person by
the inverse probability of remaining uncensored (Blanche et al. 2013). A
model must do better than two simple strategies. Treat all flags every
person. Treat none flags no person and has a net benefit of 0.
A decision curve shows net benefit across a range of thresholds. Choose the range from the clinical decision, as the first step of the analysis (Vickers et al. 2016). Do not choose a threshold from the decision curve (Kerr et al. 2016). We use 5% to 30%, around the ESC categories at 10% and 20%. The horizon is 10 years after entry.
net_benefit <- assess_net_benefit(
risk_ladder,
horizons = 10,
thresholds = (5:30) / 100,
pairs = c(primary, secondary),
validation = plan
)plot_net_benefit() draws the corrected curves of the
report model and of the same model without the progression score, with
95% confidence bands:
report_pair <- c("onset+outcome", "onset+progression+outcome")
plot_net_benefit(net_benefit, horizon = 10, model = report_pair)Below about 9%, the two models and treat all have almost the same net benefit, because the models flag most persons. From about 9% to about 22%, the report model has a higher net benefit than treat all and treat none at every threshold of the grid. In this range, it is also higher than the model without the progression score at most thresholds. Above about 22%, both models are close to treat none. Each band is the interval of one model. Because both models use the same persons, their net benefits are correlated. Because of this, overlapping bands do not show that the models are alike, and the plot cannot compare them. The change in net benefit compares them directly. Treat all uses no model, and it has no optimism. Its curve has the apparent values of the development persons.
pairs gives the change in net benefit from the reduced
model to the full model of each pair:
nb_pairs <- net_benefit$pairs
print(
nb_pairs[nb_pairs$threshold %in% c(0.1, 0.2),
c("full", "reduced", "threshold", "delta_net_benefit")],
digits = 2
)
#> full reduced threshold delta_net_benefit
#> 6 onset+progression onset 0.1 9.9e-05
#> 16 onset+progression onset 0.2 2.7e-03
#> 32 progression+outcome outcome 0.1 3.5e-03
#> 42 progression+outcome outcome 0.2 1.7e-03
#> 58 onset+progression+outcome onset+outcome 0.1 -9.7e-05
#> 68 onset+progression+outcome onset+outcome 0.2 1.6e-03The last two rows are the secondary comparison. At 20%, adding the progression score to the other two scores gives 0.0016 more net benefit. This gain has the value of 1.6 more true positives per 1000 persons, with no more false positives. At 10%, the change is -0.0001, less than 1 true positive per 1000 persons.
These changes are sizes of effect without a confidence interval, as
for delta_auc (?assess_net_benefit). The
likelihood-ratio tests of Step 4 answer whether the progression score
adds information. The decision curve answers how much that information
changes decisions. An interval for delta_net_benefit needs
a bootstrap plan with interval = "two-stage" and at least
1000 outer replicates. That plan takes several hours for this cohort,
and the tutorial does not run it (?bootstrap_plan).
The article Validation in depth continues this step with three more results:
assess_thresholds(),assess_stability().Internal validation estimates the performance in new persons from the same population. A different cohort, such as another biobank, a later period, or another ancestry group, can differ in its baseline risk, its case mix, and its measurements. External validation measures whether the models transport to such a cohort (Debray et al. 2015).
Our second biobank is also simulated, with the same rate of death and
the same effects of age and sex. It differs from the first in two ways,
as a biobank of another population can. Its rate of CAD is much higher.
If nobody died, a man with a T2D diagnosis at age 55 and average scores
would have a 40% risk of CAD within ten years, against 15% in the first
biobank. And its scores have weaker effects, with log hazard ratios
about half as large, as a score often has in an ancestry that differs
from that of its GWAS (Choi et al. 2020). id_prefix gives
its persons other identifiers, and we keep its recruited persons, as for
the first biobank.
second_biobank <- simulate_prs_data(
n = 4000L,
seed = 2027L,
hazard_ratios = c(t2d = 1.05, t2d_to_cad = 1.12, cad = 1.15),
score_correlation = 0.3,
baseline_risk = 0.40,
incident_probability = 0.3,
competing_risk = 0.2,
competing_hazard_ratios = c(t2d = 1.2),
covariate_hazard_ratios = c(age_at_diagnosis = 1.04, sex = 0.7),
competing_covariate_hazard_ratios = c(age_at_diagnosis = 1.08, sex = 0.7),
id_prefix = "E"
)
second_biobank <- second_biobank[second_biobank$recruited, ]A ladder holds the data of every person, and it cannot leave the
first biobank. export_ladder() keeps only what the
predictions need:
models holds one model for each rung, rules
the rules of the cohort, development the summaries for
describe_case_mix(), and settings the settings
of the export. print(export) lists each value that leaves
the biobank, for a disclosure review. The list has 528 lines for this
ladder, and the tutorial does not show it. Some values can come from one
or two persons, such as a knot of the age spline.
?export_ladder gives the disclosure status of each element,
for the review of each biobank.
The first biobank saves the export to a file, and the second biobank loads it:
prs_cohort() with reference = imported
builds the cohort with the rules of the first biobank: the same columns,
status codes, settings, and exclusions, and each score standardized with
the development mean, SD, and sign.
external <- prs_cohort(second_biobank, reference = imported)
external
#> <prs_cohort> recruitment index
#> Persons: 3405 Events: 1326 Competing events: 620
#> Prevalent persons: keep
#> Scores:
#> name column sign
#> onset t2d_score 1
#> progression t2d_to_cad_score 1
#> outcome cad_score 1
#> Covariates: sex, PC1, PC2, PC3, PC4, PC5, birth_year
#> Excluded: historical = 412With a second cohort, describe_cohort() gives one column
per cohort:
| Characteristic | Development N = 2,7111 |
External N = 3,4051 |
|---|---|---|
| CAD | 438 (16%) | 1,326 (39%) |
| Death | 606 (22%) | 620 (18%) |
| Age at T1 | 55 (9) | 55 (9) |
| Prevalent at recruitment | 1,848 (68%) | 2,169 (64%) |
| onset | -0.02 (0.98) | -0.01 (1.00) |
| progression | -0.01 (0.99) | -0.04 (1.00) |
| outcome | 0.00 (1.01) | -0.01 (1.01) |
| Sex | ||
| Male | 1,343 (50%) | 1,598 (47%) |
| Female | 1,368 (50%) | 1,807 (53%) |
| PC1 | 0.02 (1.03) | -0.04 (0.99) |
| PC2 | 0.00 (1.01) | -0.02 (1.00) |
| PC3 | -0.01 (0.98) | 0.02 (0.99) |
| PC4 | 0.02 (1.00) | -0.03 (1.02) |
| PC5 | -0.01 (1.00) | -0.01 (1.00) |
| Year of birth | 1952 (10) | 1952 (10) |
| 1 n (%); Mean (SD) | ||
The table needs the data of both cohorts. When the data cannot leave a biobank, each biobank makes its own column.
describe_case_mix() compares the linear predictor of
each model in the two cohorts. The development values come from the
export:
print(describe_case_mix(imported, external), digits = 3)
#> model lp_mean_development lp_sd_development
#> 1 base -0.191 0.411
#> 2 onset -0.172 0.475
#> 3 progression -0.188 0.506
#> 4 outcome -0.158 0.546
#> 5 onset+progression -0.172 0.528
#> 6 onset+outcome -0.144 0.564
#> 7 progression+outcome -0.168 0.586
#> 8 onset+progression+outcome -0.157 0.592
#> lp_mean_external lp_sd_external lp_mean_difference lp_sd_ratio
#> 1 -0.184 0.412 0.006842 1.001
#> 2 -0.164 0.479 0.007968 1.009
#> 3 -0.188 0.508 -0.000352 1.004
#> 4 -0.156 0.545 0.001856 0.998
#> 5 -0.170 0.528 0.001774 1.000
#> 6 -0.141 0.565 0.003140 1.002
#> 7 -0.170 0.585 -0.002590 0.998
#> 8 -0.158 0.590 -0.001194 0.998The two cohorts have almost the same mean and spread of the linear predictor, because the simulator draws the scores and the covariates of both biobanks from the same distributions. A larger spread in the external cohort usually gives a higher AUC, and a different mean can shift the O/E ratio (Debray et al. 2015). Here neither can explain a difference. A loss of performance must then come from a different baseline risk or from different effects of the predictors.
Collins et al. (2016) recommend at least 100 events, and preferably 200, for an external validation. The second biobank has 1326 CAD events.
With newdata, the assess_*() functions
evaluate the external cohort, and the column evaluation
says "external".
external_calibration <- assess_calibration(
imported,
horizons,
groups = 10L,
newdata = external
)
external_overall <- external_calibration$models
print(
external_overall[external_overall$model == "onset+progression+outcome",
c("horizon", "observed_events", "expected_events",
"oe_ratio", "oe_ratio_lower", "oe_ratio_upper",
"expected_risk", "observed_risk", "slope", "slope_se")],
digits = 3
)
#> horizon observed_events expected_events oe_ratio oe_ratio_lower
#> 8 5 646 206 3.13 2.9
#> 16 10 1106 359 3.08 2.9
#> oe_ratio_upper expected_risk observed_risk slope slope_se
#> 8 3.38 0.066 0.190 0.663 0.0677
#> 16 3.26 0.129 0.335 0.641 0.0522The models predict far too few events. At ten years, the O/E ratio of CAD events is 3.08. The mean predicted cumulative incidence is 13%, and the observed cumulative incidence is 33%. The tenths of predicted risk lie far above the diagonal:
The slope is 0.64 (SE 0.05), far below 1. The predicted risks are too spread out: the scores separate the persons of the second biobank less than the model expects. With the same case mix, this points to weaker effects of the scores (Debray et al. 2015). The O/E ratio and the slope show two different problems: a different baseline risk and different effects.
Simulation check (not possible with real data): both findings are correct, because the simulator gives the second biobank a higher rate of CAD and weaker effects of the scores.
The next step is to report this miscalibration, and then to update the model in the external cohort. An update can estimate a new baseline hazard, or also a new calibration slope of the linear predictor (van Houwelingen 2000; Debray et al. 2015). Report the performance before and after the update. The package does not update models.
A different baseline risk alone does not change how the models rank
the persons, but weaker effects do. rbind() puts the
corrected AUC of the first biobank next to the AUC of the second:
external_discrimination <- assess_discrimination(
imported,
horizons,
pairs = primary,
newdata = external
)
both <- rbind(discrimination$models, external_discrimination$models)
print(
both[both$model == "onset+progression+outcome" & both$horizon == 10,
c("evaluation", "n", "auc", "auc_lower", "auc_upper")],
digits = 3,
row.names = FALSE
)
#> evaluation n auc auc_lower auc_upper
#> optimism-corrected 2711 0.632 0.60 0.664
#> external 3405 0.591 0.57 0.613The AUC of the second biobank, 0.591, is lower than the corrected AUC of the first, 0.632, because the scores have weaker effects there.
Each result has the same elements and columns in every mode, and the
same element of two results stacks with rbind(). In the
external mode, the change in AUC between two models has a confidence
interval again, because the fitted models are fixed for new persons:
print(
external_discrimination$pairs[c("full", "reduced", "horizon", "delta_auc",
"delta_auc_lower", "delta_auc_upper")],
digits = 3
)
#> full reduced horizon delta_auc delta_auc_lower delta_auc_upper
#> 1 onset+progression onset 5 0.00481 -0.00830 0.0179
#> 2 progression+outcome outcome 5 0.00482 -0.00487 0.0145
#> 3 onset+progression onset 10 0.00638 -0.00624 0.0190
#> 4 progression+outcome outcome 10 0.00474 -0.00441 0.0139At ten years, both estimates of the change in AUC are above zero, and both intervals include zero. The interval is -0.006 to 0.019 for progression added to the T2D score, and -0.004 to 0.014 for progression added to the CAD score. The second biobank then does not show that the progression score improves the AUC of these models. These intervals describe the models of the first biobank, with their coefficients.
The results above evaluate predictions. A second question is whether the association of Step 4 replicates. The second biobank has its own person-level data, and it can fit its own ladder and repeat the primary tests:
external_ladder <- fit_ladder(external)
print(compare_ladder(external_ladder, pairs = primary), digits = 3)
#> full reduced added df chisq p_value delta_aic
#> 1 onset+progression onset progression 1 20.3 6.72e-06 -18.3
#> 2 progression+outcome outcome progression 1 15.9 6.72e-05 -13.9
#> delta_concordance
#> 1 0.00269
#> 2 0.00227
external_hr <- summary(external_ladder)
print(
external_hr[external_hr$model == "onset+progression+outcome",
c("term", "hr", "hr_lower", "hr_upper")],
digits = 3
)
#> term hr hr_lower hr_upper
#> 10 onset 1.06 0.996 1.12
#> 11 progression 1.11 1.051 1.18
#> 12 outcome 1.10 1.044 1.17Both primary tests have a p-value below 0.001, and the association
replicates. The hazard ratios are smaller than in the first biobank.
external has the scores on the scale of the first biobank.
Each hazard ratio is then per SD of the first biobank, and the two sets
of hazard ratios are comparable. The fit uses the design
"t1", the design of the analysis plan for hazard
ratios.
Simulation check (not possible with real data): each interval contains the value of the simulator for the second biobank (1.05, 1.12, and 1.15).
Many analyses start the follow-up of every person at the T2D
diagnosis (index = "diagnosis"), also for persons who
joined the biobank later. The early follow-up of a prevalent person,
between diagnosis and recruitment, was then not observed as it happened.
We know about it only because the person was alive when they joined.
Anyone who died before recruitment cannot be in the biobank. Under the
diagnosis index, this selection removes early deaths, so the risk of
death is too low. The risk of CAD is too high when most of the removed
deaths are from other causes, and too low when fatal CAD is common. The
recruitment index of Step 1 removes the bias in both directions.
Settings are easiest to vary by building each cohort from one list of arguments. The list holds the arguments of Step 1 apart from the index:
setup <- list(
data = biobank,
id = "IID",
status = "exit_status",
age_at_diagnosis = "age_at_diagnosis",
age_at_exit = "age_at_exit",
scores = c(
onset = "t2d_score",
progression = "t2d_to_cad_score",
outcome = "cad_score"
),
covariates = c("sex", "PC1", "PC2", "PC3", "PC4", "PC5", "birth_year"),
status_codes = list(censored = 0, event = 1, competing = 2),
event_labels = c(event = "CAD", competing = "Death")
)
recruitment <- list(
index = "recruitment",
age_at_recruitment = "age_at_recruitment"
)
cohort_diagnosis <- do.call(prs_cohort, c(setup, list(index = "diagnosis")))Simulation check (not possible with real data): a cohort of every simulated person, also those who died before they could join, estimates the true risks. The table gives the ten-year cumulative incidence of CAD and of death under each analysis:
setup_all <- setup
setup_all$data <- simulated
cohort_all <- do.call(prs_cohort, c(setup_all, list(index = "diagnosis")))
ten_year <- do.call(rbind, lapply(
list(
"all simulated persons" = cohort_all,
"recruitment index" = cohort,
"diagnosis index" = cohort_diagnosis
),
function(x) {
cumulative_risk(x, score = NULL, times = 10)$risks[
c("n", "risk", "risk_lower", "risk_upper", "competing_risk",
"competing_risk_lower", "competing_risk_upper")
]
}
))
print(ten_year, digits = 2)
#> n risk risk_lower risk_upper competing_risk
#> all simulated persons 3000 0.12 0.11 0.14 0.18
#> recruitment index 2711 0.12 0.11 0.14 0.17
#> diagnosis index 2823 0.13 0.12 0.14 0.12
#> competing_risk_lower competing_risk_upper
#> all simulated persons 0.16 0.19
#> recruitment index 0.15 0.19
#> diagnosis index 0.11 0.14The recruitment index agrees with the truth. The diagnosis index gives a risk of death that is too low by 5 percentage points. Its risk of CAD is 0.8 points higher than the truth, the direction expected when the removed deaths are not from CAD. In a cohort of this size, that difference is within the confidence intervals, and the risk of death shows the bias clearly. A model fitted under the diagnosis index carries this bias into its absolute risks. The bias is in the baseline hazards, so the hazard ratios change much less:
analyses <- list(
"recruitment index" = cohort,
"diagnosis index" = cohort_diagnosis,
"incident only" = do.call(
prs_cohort,
c(setup, recruitment, list(prevalent = "drop"))
),
"1-year landmark" = do.call(
prs_cohort,
c(setup, recruitment, list(entry_lag = 1))
)
)
progression_hr <- do.call(rbind, lapply(names(analyses), function(analysis) {
fit <- fit_ladder(analyses[[analysis]])
row <- summary(fit)
row <- row[
row$model == "onset+progression+outcome" & row$term == "progression",
]
data.frame(
analysis = analysis,
persons = nrow(analyses[[analysis]]$data),
events = sum(analyses[[analysis]]$data$status),
row[c("hr", "hr_lower", "hr_upper")],
row.names = NULL
)
}))
#> Warning in fit_ladder(analyses[[analysis]]): The largest model has 7.92 events
#> per coefficient, fewer than 10.
print(progression_hr, digits = 3)
#> analysis persons events hr hr_lower hr_upper
#> 1 recruitment index 2711 438 1.21 1.10 1.34
#> 2 diagnosis index 2823 550 1.25 1.14 1.37
#> 3 incident only 863 103 1.47 1.19 1.83
#> 4 1-year landmark 2686 425 1.20 1.08 1.33Each fit uses the default design "t1", and the
comparison of the indexes stays within one design. The other designs
need the recruitment index, with incident and prevalent persons. The
last two rows answer common reviewer questions.
prevalent = "drop" keeps only incident persons, who
were followed from their diagnosis.entry_lag starts each person’s follow-up no earlier
than a fixed time after the diagnosis. A lag of one year ignores CAD
found during the work-up of a new T2D diagnosis. It gives a landmark
analysis of persons still alive and free of CAD one year after
diagnosis.The intervals of the HR of the progression score, adjusted for the
other two, overlap in the four analyses. The incident-only estimate,
1.47 (95% CI 1.19 to 1.83), is the least precise, because it has the
fewest events. With 103 CAD events, its largest model has fewer than 10
events per coefficient, and fit_ladder() warns.
Simulation check (not possible with real data): each of the four intervals contains the true value of 1.25.
Report the recruitment index as the main analysis. The diagnosis index can be a sensitivity analysis of the hazard ratios, but not of the absolute risks.
Many analyses treat a death as the end of follow-up, as if the person had left the study. This step shows what changes. A death treated as censoring gives estimates of net risks. A net risk is the risk in a world where nobody dies before CAD. Net risks are too high for a real patient (Wolbers et al. 2009). These estimates are net risks only if death is independent of CAD, and the data cannot check this assumption (Tsiatis 1975).
We build a second cohort from the same persons, with death as censoring:
# Assign the whole list of status codes. modifyList() merges the two lists,
# keeps `competing = 2`, and prs_cohort() then stops with an error.
setup_censored <- setup
setup_censored$status_codes <- list(censored = c(0, 2), event = 1)
setup_censored$event_labels <- c(event = "CAD")
cohort_censored <- do.call(prs_cohort, c(setup_censored, recruitment))
cohort_censored
#> <prs_cohort> recruitment index
#> Persons: 2711 Events: 438
#> Prevalent persons: keep
#> Scores:
#> name column sign
#> onset t2d_score 1
#> progression t2d_to_cad_score 1
#> outcome cad_score 1
#> Covariates: sex, PC1, PC2, PC3, PC4, PC5, birth_year
#> Excluded: historical = 112With death as censoring, cumulative_risk() gives one
minus the Kaplan-Meier estimate. That estimate keeps a positive risk of
CAD for a person who died (Putter et al. 2007). The table below gives
one minus Kaplan-Meier minus the cumulative incidence, in percentage
points, by quintile and time:
incidence <- cumulative_risk(
cohort,
score = "progression",
times = c(5, 10, 15),
groups = 5L
)
one_minus_km <- cumulative_risk(
cohort_censored,
score = "progression",
times = c(5, 10, 15),
groups = 5L
)
gap <- incidence$risks[c("group", "time")]
stopifnot(identical(one_minus_km$risks[c("group", "time")], gap))
gap$points <- 100 * (one_minus_km$risks$risk - incidence$risks$risk)
round(xtabs(points ~ group + time, gap), 1)
#> time
#> group 5 10 15
#> 1 0.2 0.9 2.1
#> 2 0.3 1.0 2.7
#> 3 0.2 1.5 3.0
#> 4 0.2 1.1 2.8
#> 5 0.4 1.6 4.1One minus Kaplan-Meier is larger in every cell. The gap grows with the number of deaths before the time: it is at most 0.4 points at 5 years and up to 4.1 points at 15 years.
The models for CAD are the same in both cohorts, because each treats a death as censoring. Only the absolute risks differ.
Simulation check (not possible with real data): the code below
compares the mean predicted ten-year risk at the diagnosis with the mean
true risk, which is a cumulative incidence. The simulator gives the true
risk from the diagnosis only. prediction_data() without
age_at_entry predicts from the diagnosis, also for
prevalent persons. For a risk from the diagnosis, the rung of
ladder is the more precise one in these data (Step 7), and
this comparison uses it:
persons_raw <- biobank[match(cohort$data$id, biobank$IID), ]
at_diagnosis <- prediction_data(persons_raw, cohort)
true_risk <- persons_raw$true_risk
report_fit_t1 <- ladder$models[["onset+progression+outcome"]]
round(c(
true = mean(true_risk),
cumulative_incidence = mean(
predict_risk(report_fit_t1, at_diagnosis, horizon = 10)
),
net_risk = mean(
predict_risk(report_fit_t1, at_diagnosis, horizon = 10, competing = FALSE)
)
), 3)
#> true cumulative_incidence net_risk
#> 0.124 0.123 0.138The mean cumulative incidence is close to the mean true risk, and the mean net risk is too high.
The difference matters at a guideline threshold.
assess_thresholds() gives the share of persons with a
predicted risk of 20% or more. These risks are from entry, and they use
the design of the analysis plan for absolute risks,
checks$design. The first value uses the net risk of a
ladder fitted with death as censoring. The second value uses the
cumulative incidence of the same rung of risk_ladder, with
deaths as a competing event. Both values are apparent:
ladder_censored <- fit_ladder(cohort_censored, design = checks$design)
flagged <- function(ladder) {
models <- assess_thresholds(ladder, horizons = 10, thresholds = 0.2)$models
models$above[models$model == "onset+progression+outcome"]
}
above <- c(
net_risk = flagged(ladder_censored),
cumulative_incidence = flagged(risk_ladder)
)
round(above, 3)
#> net_risk cumulative_incidence
#> 0.225 0.143The net risk flags 23% of the persons, and the cumulative incidence flags 14%. A model that ignores death puts too many persons above a treatment threshold (Wolbers et al. 2009).
The Fine-Gray model is a different regression model for the cumulative incidence (Fine and Gray 1999). It is not part of the ladder. The article Fine-Gray models with the data of a cohort gives the reasons, and it shows how to fit a Fine-Gray model for comparison.
The question was: among people who already have T2D, does the progression score tell us who will develop CAD, beyond what the T2D score and the CAD score already tell us? The table collects the results that answer it.
| Result | Estimate | Step |
|---|---|---|
| HR for CAD per SD of the progression score, adjusted for the other two scores | 1.21 (95% CI 1.10 to 1.34) | 3 |
| Likelihood-ratio tests of the two primary comparisons | Both p-values below 0.001 | 4 |
| Likelihood-ratio test of the secondary comparison | p-value below 0.001 | 4 |
| Ten-year risk of CAD at the 10th and the 90th percentile of the progression score, report model | 10% (95% CI 7% to 14%) and 17% (12% to 22%) | 7 |
| Change in AUC at ten years, for progression added to the T2D score and to the CAD score | 0.027 and 0.016 | 8 |
| Change in net benefit at 10% and 20%, for progression added to the other two scores | -0.1 and 1.6 true positives per 1000 persons | 8 |
| Second biobank: O/E ratio of the report model at ten years | 3.08 (95% CI 2.90 to 3.26) | 9 |
| Second biobank: change in AUC at ten years, for the same two additions | 0.006 (-0.006 to 0.019) and 0.005 (-0.004 to 0.014) | 9 |
| Second biobank: likelihood-ratio tests of the two primary comparisons, in its own ladder | Both p-values below 0.001 | 9 |
The answer is yes, and the size of the gain is modest. The progression score is associated with the rate of CAD beyond the T2D score, beyond the CAD score, and beyond both scores together. The added information changes the predictions by a small amount. The net benefit rises at the threshold of 20%, and it is almost unchanged at 10%. The changes in AUC and in net benefit of the first biobank are sizes of effect without a confidence interval (Step 8). The second biobank replicates the two primary tests in its own ladder. For the predictions of the models of the first biobank, the intervals of both changes in AUC include zero there. The models predict too few events there, and they need an update before use in that population (Step 9).
Simulation check (not possible with real data): the simulator gave the progression score a hazard ratio of 1.25, and the interval of the first row contains this value.
All values of this tutorial come from simulated data. They show the form of a report, and they are not results about T2D and CAD.
The PRS-RS reporting standard (Wand et al. 2021) lists what a PRS risk-model paper must report. prsladder gives the items that come from the analysis:
| PRS-RS item | Where to find it |
|---|---|
| Study design and recruitment | The analysis plan, and summary(cohort) for the incident
and prevalent persons and the median follow-up |
| Participant demographics and clinical characteristics | describe_cohort(cohort, external), with one column per
cohort |
| Non-genetic variables | cohort$covariates, the age spline, and the terms of the
design |
| Outcome of interest | summary(cohort) for the number of events of each
kind |
| Missing data | cohort$n_excluded, the persons removed for each
reason |
| Risk model type | Cox models on the years since the first diagnosis, or on the years
since entry under the design "index", and the absolute risk
at each horizon from entry. With a competing event, the models are
cause-specific and the risk is the cumulative incidence |
| Integrated risk model(s) description and fitting | fit_ladder(): each model has its scores, the
covariates, and the age spline. formula() of a model gives
its terms, and ladder$coefficients gives every
coefficient |
| PRS distribution | summary(cohort), cohort$scores, and
describe_cohort() |
| Risk model predictive ability | summary(ladder) for the HR per SD,
compare_ladder(), plot_ladder(), and
standardized_risk() for the absolute risk at score
percentiles |
| Risk model discrimination | assess_discrimination(),
plot_discrimination(), and
assess_thresholds() |
| Risk model calibration | assess_calibration(), plot_calibration(),
and shrinkage for the corrected slope |
| Subgroup analyses | The package has no function for subgroups. Name the subgroups in the
analysis plan, build one cohort for each subgroup with
prs_cohort(), and fit a ladder in each cohort |
| Risk model intended uses | assess_net_benefit() and
plot_net_benefit() for the net benefit at the clinical
thresholds |
A report of this kind of study also needs results that PRS-RS does not name:
| Other result | Where to find it |
|---|---|
| Risk by score group, figure | plot_risk() with its risk table,
plot_stacked(), and plot_gradient() for many
groups |
| Risk by score group, table | describe_risk(), and ratio and
case_share in cumulative_risk()$risks |
| Time lost by score group | cumulative_risk()$time_lost |
| Competing events | The hazard ratios and the cumulative incidence of both events
(Latouche et al. 2013): ladder$competing_coefficients,
plot_ladder(ladder, event = "both"),
plot_risk(..., event = "both"), and
describe_risk() |
| Design of the absolute risks | The three rows of check_design()$tests, and the
statement that the analysis plan fixed the rule |
| Mode of each evaluation | The evaluation column of every result: apparent,
optimism-corrected, or external |
| Internal validation | The number of replicates and the seed of
bootstrap_plan(), and the Monte Carlo standard errors of
the optimism |
| External validation | The settings of export_ladder(), such as
min_cell_count, and describe_case_mix() for
the two cohorts |
You must supply the rest: how the scores were built and their PGS Catalog IDs, the genotyping and quality control, the definitions of T2D, CAD, and death (for example ICD codes), and the ancestry of every sample. State whether the GWAS behind a score included participants of your biobank. Any overlap inflates the results (Choi et al. 2020).
?fit_ladder). The rule of
check_design() chooses the design from the data, and the
bootstrap does not repeat this choice. The rule has not been studied
with competing events or with an entry lag
(?check_design).fit_ladder(), and it
does not correct for such a choice (?validation).?validation). For a model with an AUC near 0.5, the 95%
interval of the corrected AUC covered the true value in 89% to 90% of
the samples. The coverage of the other intervals is not measured.Aalen OO, Johansen S. An empirical transition matrix for non-homogeneous Markov chains based on censored observations. Scandinavian Journal of Statistics 1978;5:141-150.
Austin PC, Harrell FE Jr, van Klaveren D. Graphical calibration curves and the integrated calibration index (ICI) for survival models. Statistics in Medicine 2020;39:2714-2742.
Benichou J, Gail MH. Estimates of absolute cause-specific risk in cohort studies. Biometrics 1990;46:813-826.
Blanche P, Dartigues J-F, Jacqmin-Gadda H. Estimating and comparing time-dependent areas under receiver operating characteristic curves for censored event times with competing risks. Statistics in Medicine 2013;32:5381-5397.
Chiou SH, Qian J, Mormino E, Betensky RA. Permutation tests for general dependent truncation. Computational Statistics and Data Analysis 2018;128:308-324.
Choi SW, Mak TS-H, O’Reilly PF. Tutorial: a guide to performing polygenic risk score analyses. Nature Protocols 2020;15:2759-2772.
Collins GS, Ogundimu EO, Altman DG. Sample size considerations for the external validation of a multivariable prognostic model: a resampling study. Statistics in Medicine 2016;35:214-226.
Crowson CS, Atkinson EJ, Therneau TM. Assessing calibration of prognostic risk scores. Statistical Methods in Medical Research 2016;25:1692-1706.
Debray TPA, Vergouwe Y, Koffijberg H, Nieboer D, Steyerberg EW, Moons KGM. A new framework to enhance the interpretation of external validation studies of clinical prediction models. Journal of Clinical Epidemiology 2015;68:279-289.
Dudbridge F, Allen RJ, Sheehan NA, et al. Adjustment for index event bias in genome-wide association studies of subsequent events. Nature Communications 2019;10:1561.
Emerging Risk Factors Collaboration; Sarwar N, Gao P, Seshasai SR, et al. Diabetes mellitus, fasting blood glucose concentration, and risk of vascular disease: a collaborative meta-analysis of 102 prospective studies. Lancet 2010;375:2215-2222.
Fine JP, Gray RJ. A proportional hazards model for the subdistribution of a competing risk. Journal of the American Statistical Association 1999;94:496-509.
Fry A, Littlejohns TJ, Sudlow C, Doherty N, Adamska L, Sprosen T, Collins R, Allen NE. Comparison of sociodemographic and health-related characteristics of UK Biobank participants with those of the general population. American Journal of Epidemiology 2017;186:1026-1034.
Gerds TA, Schumacher M. Consistent estimation of the expected Brier score in general survival models with right-censored event times. Biometrical Journal 2006;48:1029-1040.
Geskus RB. Cause-specific cumulative incidence estimation and the Fine and Gray model under both left truncation and right censoring. Biometrics 2011;67:39-49.
Harrell FE Jr, Lee KL, Mark DB. Multivariable prognostic models: issues in developing models, evaluating assumptions and adequacy, and measuring and reducing errors. Statistics in Medicine 1996;15:361-387.
Kerr KF, Brown MD, Zhu K, Janes H. Assessing the clinical impact of risk prediction models with decision curves: guidance for correct interpretation and appropriate use. Journal of Clinical Oncology 2016;34:2534-2540.
Latouche A, Allignol A, Beyersmann J, Labopin M, Fine JP. A competing risks analysis should report results on all cause-specific hazards and cumulative incidence functions. Journal of Clinical Epidemiology 2013;66:648-653.
Makuch RW. Adjusted survival curve estimation using covariates. Journal of Chronic Diseases 1982;35:437-443.
Marx N, Federici M, Schuett K, et al. 2023 ESC Guidelines for the management of cardiovascular disease in patients with diabetes. European Heart Journal 2023;44:4043-4140.
Pepe MS, Kerr KF, Longton G, Wang Z. Testing for improvement in prediction model performance. Statistics in Medicine 2013;32:1467-1482.
Putter H, Fiocco M, Geskus RB. Tutorial in biostatistics: competing risks and multi-state models. Statistics in Medicine 2007;26:2389-2430.
SCORE2-Diabetes Working Group and the ESC Cardiovascular Risk Collaboration. SCORE2-Diabetes: 10-year cardiovascular risk estimation in type 2 diabetes in Europe. European Heart Journal 2023;44:2544-2556.
Steyerberg EW, Harrell FE Jr, Borsboom GJ, Eijkemans MJ, Vergouwe Y, Habbema JD. Internal validation of predictive models: efficiency of some procedures for logistic regression analysis. Journal of Clinical Epidemiology 2001;54:774-781.
Tsai W-Y. Testing the assumption of independence of truncation time and failure time. Biometrika 1990;77:169-177.
Tsiatis A. A nonidentifiability aspect of the problem of competing risks. Proceedings of the National Academy of Sciences 1975;72:20-22.
van Houwelingen HC. Validation, calibration, revision and combination of prognostic survival models. Statistics in Medicine 2000;19:3401-3415.
van Houwelingen JC, le Cessie S. Predictive value of statistical models. Statistics in Medicine 1990;9:1303-1325.
Vickers AJ, Elkin EB. Decision curve analysis: a novel method for evaluating prediction models. Medical Decision Making 2006;26:565-574.
Vickers AJ, Van Calster B, Steyerberg EW. Net benefit approaches to the evaluation of prediction models, molecular markers, and diagnostic tests. BMJ 2016;352:i6.
Wand H, Lambert SA, Tamburro C, et al. Improving reporting standards for polygenic scores in risk prediction studies. Nature 2021;591:211-219.
Wolbers M, Koller MT, Witteman JCM, Steyerberg EW. Prognostic models with competing risks: methods and application to coronary risk prediction. Epidemiology 2009;20:555-561.