1install.packages("stringr")- 1
- Download and install the package. You do this once, ever.
Getting Started with R
In the next two practicals we will develop and validate prediction models. Those practicals are about prediction modelling, and they assume a certain level of understanding of R. This practical is meant to obtain and test this.
We start with the basics, but we cover them fairly quickly. The later sections deal with subsetting, factors, lists, formulas, functions, and loops. These are used constantly in Practicals 2 and 3, usually without much explanation.
Reading this document is not enough. Open RStudio, create a new script, and type the code out yourself. Every exercise has a worked solution that can be unfolded, but it is worth spending a few minutes on the exercise before looking at it.
The exercises in particular are worth doing.
Most functionality in R lives in a package. A package has to be installed once on your machine, and loaded again in every new R session in which you want to use it.
1install.packages("stringr")There are then two ways to use a function from an installed package:
package::function() calls a function without loading the package. This is more typing, but it makes it unambiguous where the function came from.
[1] "HELLO WORLD"
[1] "HELLO WORLD"
In Practicals 2 and 3 the :: form is used a lot (rms::lrm(), pROC::roc(), ggplot2::ggplot()). The reason is that when a script uses ten packages, :: makes clear which package each function comes from, and it avoids problems when two packages define a function with the same name.
Tip: you only have to install a package once, but you must load it with library() every time you (re)start R.
Tip: an Rtools installation warning on Windows can be resolved at https://cran.rstudio.com/bin/windows/Rtools/.
Practical 2 opens with a third option, pacman::p_load(), which loads several packages at once and installs any that are missing. It is therefore useful to install {pacman} now if you do not have it already installed on your computer:
install.packages("pacman")
1pacman::p_load("dplyr", "ggplot2")The console is useful for a quick calculation or for inspecting an object, but code typed there is lost as soon as R is closed. Anything we want to reproduce, share, or hand to a reviewer belongs in a script (.R).
A second requirement for reproducibility is set.seed(). Many of the procedures in the next practicals involve randomness, such as multiple imputation, bootstrapping, cross-validation, and splitting data. Without a seed, we obtain a different answer every time the script is run.
[1] -0.6264538 0.1836433 -0.8356286
[1] -0.6264538 0.1836433 -0.8356286
This is why the scripts in Practicals 2 and 3 all start with set.seed(1).
This dataset contains 2,159 patients from the international and US Tirilazad trials (distributed here for didactic purposes only). The primary outcome was the Glasgow Outcome (range 1 through 5) at 6 months.
The TBI dataset was sent to you together with this practical. You can also download it directly from this page:
Once downloaded, place the file somewhere on your own device and load it from there:
# Specify path of TBI dataset
path <- "C:/users/avid_PREDICT_student/documents/pint/TBI.txt"
# Load TBI data
tbi <- rio::import(path)Warning in (function (input = "", file = NULL, text = NULL, cmd = NULL, :
Detected 24 column names but the data has 25 columns (i.e. invalid file). Added
an extra default column name for the first column which is guessed to be row
names or an index. Use setnames() afterwards if this guess is not correct, or
fix the file write command that created the file to create a valid file.
Tip: make sure to use forward slashes (/) in your file paths!
The warning we receive is the result of row numbers being present in the .txt file (as also guessed by import()). Given that import() guessed correct, we can ignore this warning and treat the extra column V1 as individual identifiers.
It is good practice to look at a dataset before doing anything with it. The four functions below are the ones we use most often for this.
str() is probably the most useful function in R for checking whether an object contains what we think it does.
[1] 2159 25
[1] "V1" "trial" "d.gos" "d.mort" "d.unfav" "cause"
[7] "age" "d.motor" "d.pupil" "pupil.i" "hypoxia" "hypotens"
[13] "ctclass" "tsah" "edh" "cisterns" "shift" "d.sysbpt"
[19] "glucose" "glucoset" "ph" "sodium" "sodiumt" "hb"
[25] "hbt"
V1 trial d.gos d.mort d.unfav cause age
1 1 Tirilazad International good recovery 0 0 Motorbike 14
2 2 Tirilazad International good recovery 0 0 Motorbike 14
3 3 Tirilazad International good recovery 0 0 domestic/fall 14
d.motor d.pupil pupil.i hypoxia hypotens ctclass tsah edh
1 5 both reactive both reactive 0 0 2 0 0
2 4 both reactive both reactive 0 0 2 0 0
3 4 both reactive both reactive 1 0 4 1 0
cisterns shift d.sysbpt glucose glucoset ph sodium sodiumt hb hbt
1 1 0 119.09 7.700000 7.700000 7.350 143 143 15.0 15.0
2 1 0 130.71 7.400000 7.400000 7.330 143 143 15.0 15.0
3 NA NA 136.26 7.555556 7.555556 7.489 141 141 12.8 12.8
'data.frame': 2159 obs. of 8 variables:
$ V1 : int 1 2 3 4 5 6 7 8 9 10 ...
$ trial : chr "Tirilazad International" "Tirilazad International" "Tirilazad International" "Tirilazad International" ...
$ d.gos : chr "good recovery" "good recovery" "good recovery" "moderate disability" ...
$ d.mort : int 0 0 0 0 0 0 0 0 0 0 ...
$ d.unfav: int 0 0 0 0 0 0 0 0 0 0 ...
$ cause : chr "Motorbike" "Motorbike" "domestic/fall" "Motorbike" ...
$ age : int 14 14 14 14 14 14 14 14 14 14 ...
$ d.motor: int 5 4 4 4 5 3 5 5 4 5 ...
The c() function combines values into a vector. Most objects in R are built out of vectors.
a.
1:3 is shorthand for a sequence from 1 to 3.
seq() builds a sequence with a chosen step size.
rep() repeats a value; here, five ones.
[1] 5
[1] 2 4 6
[1] 2 4 6
R is built around operations on whole vectors. For example, tbi$age * 12 converts 2,159 ages into months in one step. If we find ourselves writing a loop to do something to each element of a vector, there is usually a vectorised alternative that is both shorter and faster.
A vector holds exactly one type of data. The three types we encounter in this course are numeric, character, and logical.
age holds numbers.
cause holds text such as "Assault" or "Motorbike".
TRUE/FALSE values: a logical vector.
[1] "integer"
[1] "character"
[1] "logical"
Because a vector can only hold one type, R silently coerces everything to the most flexible type present when types are mixed:
TRUE becomes 1; logicals sit “below” numbers in the hierarchy.
[1] 1 2 3
[1] "1" "2" "3"
[1] 1 2 1
The last rule is more useful than it may look:
TRUE counts as 1 and FALSE as 0, so summing a logical vector counts how many are TRUE.
TRUE.
[1] 299
[1] 0.13849
[1] 0 0 0 0 0 0
We can convert types on purpose with the as.*() family, but only when the conversion makes sense:
1as.numeric(tbi$cause) |> head()"Assault" as a number, so it returns NA and warns you. Whenever you see “NAs introduced by coercion”, you have tried to turn text into numbers.
[1] NA NA NA NA NA NA
R distinguishes three things that all look like “nothing”:
NA is a missing value: the patient exists, the value is unknown. is.na() returns a logical vector, and summing it counts the missings.
NaN (“not a number”) is the result of an undefined mathematical operation.
NULL is the absence of an object. tbi has no column region, so R returns NULL.
[1] 123
[1] NaN
NULL
Missing values propagate through calculations, which is a common source of confusion:
NA.
na.rm = TRUE tells the function to drop missings first. Many summary functions have this argument.
is.na().
[1] NA
[1] 2.333333
[1] NA NA NA NA
[1] FALSE FALSE TRUE FALSE
Practicals 2 and 3 spend much of their time getting things out of objects: columns out of data frames, coefficients out of model fits, and levels out of categorical variables. This part covers what is needed for that, namely subsetting, factors, lists, and formulas.
A matrix is rectangular and holds a single type throughout, which makes it suitable for mathematical operations.
%*%.
[,1] [,2] [,3]
[1,] 1 2 3
[2,] 4 5 6
[1] 2 3
[,1] [,2] [,3]
[1,] 1 4 9
[2,] 16 25 36
[,1] [,2]
[1,] 14 32
[2,] 32 77
Matrix algebra reappears in Practical 3, where the model matrix x is multiplied by a coefficient vector to recompute linear predictors by hand.
A data frame is also rectangular, but each column may have its own type. This is the standard structure for patient data, and it is what tbi is.
1patients <- data.frame(
id = 1:3,
name = c("Anna", "Ben", "Chen"),
age = c(28, 34, 51)
)
patients
class(patients)
2sapply(patients, class)sapply() later.)
id name age
1 1 Anna 28
2 2 Ben 34
3 3 Chen 51
[1] "data.frame"
id name age
"integer" "character" "numeric"
[, [[, and $R has three subsetting operators, and a fair amount of confusion comes from mixing them up.
[row, column]1tbi[1, ]
2tbi[1:3, c("age", "cause")]
3tbi[, "age"] |> head()
4tbi[tbi$age > 70, c("age", "d.unfav")] |> head()
5tbi[-(1:2150), c("age", "cause")]tbi[1, ] is a row, tbi[1] is a column.
age and cause.
V1 trial d.gos d.mort d.unfav cause age d.motor
1 1 Tirilazad International good recovery 0 0 Motorbike 14 5
d.pupil pupil.i hypoxia hypotens ctclass tsah edh cisterns shift
1 both reactive both reactive 0 0 2 0 0 1 0
d.sysbpt glucose glucoset ph sodium sodiumt hb hbt
1 119.09 7.7 7.7 7.35 143 143 15 15
age cause
1 14 Motorbike
2 14 Motorbike
3 14 domestic/fall
[1] 14 14 14 14 14 14
age d.unfav
1113 71 1
1114 73 1
1115 74 0
1116 75 0
1117 76 1
1118 79 1
age cause
2151 65 domestic/fall
2152 65 Assault
2153 68 other
2154 68 domestic/fall
2155 68 domestic/fall
2156 68 Motorbike
2157 69 other
2158 70 domestic/fall
2159 77 domestic/fall
[ versus [[The rule is that [ keeps the container, whereas [[ takes a single element out of it.
$ does the same as [[ here, with less typing.
[1] "data.frame"
[1] "integer"
[1] "integer"
Practicals 2 and 3 nearly always write tbi[["age"]] rather than tbi$age. There are two reasons for this.
1tbi$ag |> head()$ performs partial matching, so ag is silently resolved to age. This is convenient when typing in the console, but in a script it means that a typo returns the wrong column instead of an error.
[1] 14 14 14 14 14 14
$ takes the name literally, looks for a column called my_var, does not find one, and returns NULL.
[[ evaluates the variable first, so it does find the age column. This is what makes loops over variable names possible, which we need in Practical 3.
NULL
[1] 14 14 14 14 14 14
$ is fine when typing in the console. In a script, [["name"]] is safer, and it is the only option when the column name is stored in a variable.
1tbi$assault <- as.numeric(tbi$cause == "Assault")
2tbi[["elderly"]] <- as.numeric(tbi$age > 65)
tbi[, c("assault", "elderly")] |> head()
3tbi$assault <- NULL
4"assault" %in% names(tbi)[[ form does exactly the same and is preferred in scripts.
NULL deletes a column.
%in% tests membership and returns TRUE/FALSE. You will see "glmnet" %in% class(fit) in Practical 3.
assault elderly
1 0 0
2 0 0
3 0 0
4 0 0
5 0 0
6 0 0
[1] FALSE
A factor is the type R uses for categorical variables. It looks like text, but it is stored as integer codes together with a set of levels. This is not only a matter of presentation, because it changes the model that is fitted.
1cause_f <- factor(tbi$cause)
class(cause_f)
2levels(cause_f)
3table(cause_f)
4as.integer(cause_f) |> head()[1] "factor"
[1] "Assault" "domestic/fall" "Motorbike"
[4] "other" "Road traffic accident"
cause_f
Assault domestic/fall Motorbike
134 370 420
other Road traffic accident
387 848
[1] 3 3 2 3 3 3
Take d.motor, the admission motor score, which is imported as a number from 1 to 6.
1fit_num <- glm(d.unfav ~ d.motor, data = tbi, family = binomial)
coef(fit_num)
2fit_fac <- glm(d.unfav ~ factor(d.motor), data = tbi, family = binomial)
coef(fit_fac)d.motor gets one coefficient. The model then assumes that the step from 1 to 2 has the same effect as the step from 5 to 6.
(Intercept) d.motor
2.2739937 -0.6886543
(Intercept) factor(d.motor)2 factor(d.motor)3 factor(d.motor)4
0.5877867 0.3410828 -0.3646431 -1.0386534
factor(d.motor)5 factor(d.motor)6
-1.8082888 -2.2275299
If a categorical variable is coded with numbers, R has no way of knowing that it is categorical. It treats the variable as continuous and fits a single coefficient, which is usually not meaningful. Practical 2 performs this conversion before modelling:
The first level is the reference category, so its order matters for interpretation.
pupil_f <- factor(
tbi$d.pupil,
1 levels = c("no reactive pupils", "one reactive", "both reactive"),
2 labels = c("none", "one", "both")
)
3table(pupil_f, useNA = "ifany")
4levels(pupil_f)[1]
5pupil_ref <- relevel(pupil_f, ref = "both")
levels(pupil_ref)levels states which values exist and in what order. We choose the order ourselves, and it is only alphabetical if we make it so.
labels renames them, in the same order. This is how Practical 2 turns the pupil text into 0, 1, 2.
useNA = "ifany" also shows the missing values, which table() hides by default. It is worth checking this at least once for every categorical variable.
relevel() makes a different category the reference. This changes the coefficients, but not the fit of the model itself.
pupil_f
none one both <NA>
327 279 1430 123
[1] "none"
[1] "both" "none" "one"
If the labels of a factor are numbers, converting it with as.numeric() returns the internal codes rather than the numbers we see.
A list is a container that can hold anything, including objects of different types and lengths. It is the most flexible structure in R, and most complicated R objects are built out of lists.
1info <- list(
study = "Tirilazad",
n = nrow(tbi),
predictors = c("age", "d.motor", "d.pupil")
)
2names(info)
3info[["n"]]
4info$predictors[2]
5class(info["n"])
6class(info[["n"]])
7str(info)[[.
[ returns a list containing that element.
[[ returns the element itself. This is the same rule as for data frames, which are in fact a special kind of list.
str() shows the shape of the whole thing.
[1] "study" "n" "predictors"
[1] 2159
[1] "d.motor"
[1] "list"
[1] "integer"
List of 3
$ study : chr "Tirilazad"
$ n : int 2159
$ predictors: chr [1:3] "age" "d.motor" "d.pupil"
When we fit a model, what we get back is a list.
glm (and lm), which is what tells summary() and predict() how to behave.
[1] "glm" "lm"
[1] "coefficients" "residuals" "fitted.values"
[4] "effects" "R" "rank"
[7] "qr" "family" "linear.predictors"
[10] "deviance" "aic" "null.deviance"
[13] "iter" "weights" "prior.weights"
[16] "df.residual" "df.null" "y"
[19] "converged" "boundary" "model"
[22] "call" "formula" "terms"
[25] "data" "offset" "control"
[28] "method" "contrasts" "xlevels"
Most of what Practical 2 does with a model comes down to taking elements out of this list:
1fit[["coefficients"]]
2fit[["coefficients"]][["age"]]
3coef(fit)[["(Intercept)"]]
4head(fit[["fitted.values"]])
5head(fit[["linear.predictors"]])
6fit[["df.residual"]][[ too. This gives the log odds ratio for age.
coef(), residuals(), fitted(), and predict().
(Intercept) age factor(d.motor)2 factor(d.motor)3
-0.74859525 0.03749433 0.50441704 -0.22763980
factor(d.motor)4 factor(d.motor)5 factor(d.motor)6
-0.99526136 -1.77747771 -2.26360472
[1] 0.03749433
[1] -0.7485952
1 2 3 4 5 6
0.1190820 0.2281238 0.2281238 0.2281238 0.1190820 0.3890483
1 2 3 4 5 6
-2.0011523 -1.2189359 -1.2189359 -1.2189359 -2.0011523 -0.4513144
[1] 2152
names(), str(), and class() will tell us. If str(fit) prints too much, str(fit, max.level = 1) gives a shorter overview. This is usually the quickest way to find out what an unfamiliar model object contains.
Because the model’s class determines how functions behave, code that must handle several model types tests it explicitly:
Every model in Practicals 2 and 3 is specified with a formula of the form outcome ~ predictors. A formula is an object in its own right, and not just part of the function call.
[1] "formula"
[1] "d.unfav" "age" "d.motor" "cause"
Because a formula is an object, we can define a model in one place and use it in another:
1fit_f <- glm(f, data = tbi, family = binomial)
2length(coef(fit_f))
3f2 <- update(f, . ~ . + hypoxia)
f2glm() exactly like any other argument.
cause is still a character variable here. glm() converts character predictors into factors automatically, which is why we obtain more than three coefficients. It is better not to rely on this and to declare the factors ourselves.
update() modifies a formula. The dots mean “keep the existing outcome” and “keep the existing predictors”, and we add one more.
[1] 7
d.unfav ~ age + d.motor + cause + hypoxia
Some formula notation that we will come across:
| Notation | Meaning |
|---|---|
y ~ x1 + x2 |
Two predictors, additive |
y ~ 1 |
Intercept only, no predictors |
y ~ . |
All remaining columns as predictors |
y ~ x1 * x2 |
Both main effects and their interaction |
y ~ offset(lp) |
Include lp with its coefficient fixed at 1 |
rms::rcs(age, 4) |
Age as a restricted cubic spline with 4 knots |
Surv(time, status) ~ x |
A survival outcome, for Cox models |
The offset() term is worth a separate note, because it is not obvious what it does when it first appears in Practical 2. Fitting y ~ offset(lp) estimates only an intercept, while the coefficient of lp is fixed at 1. That is exactly the definition of the calibration intercept. Below you find a preview on how to compute this, but we will go into more detail in Practical 2.
1cal_data <- data.frame(
d.unfav = fit[["y"]],
lp = fit[["linear.predictors"]]
)
2cal_int <- coef(glm(d.unfav ~ offset(lp),
data = cal_data, family = binomial))[[1]]
3cal_slope <- coef(glm(d.unfav ~ lp,
data = cal_data, family = binomial))[["lp"]]
4c(intercept = cal_int, slope = cal_slope)fit[["y"]] rather than from tbi ensures that the two have the same length, also when the model dropped patients with missing values.
offset() fixes the coefficient of lp at 1.
intercept slope
2.506454e-11 1.000000e+00
Rows can be filtered with base R’s subset(), or with dplyr::filter(), which most people find more readable in a chain.
library(dplyr)
1subset(tbi, age > 50) |> nrow()
2filter(tbi, age > 50) |> nrow()
3tbi |> filter(age > 50) |> nrow()
4tbi |> filter(age > 50, d.unfav == 1) |> nrow(){dplyr} equivalent.
|> passes the left-hand side as the first argument of the right-hand function. Practical 2 also uses %>%, the {magrittr} pipe, which behaves the same way here.
[1] 299
[1] 299
[1] 299
[1] 167
mutate() creates or changes columns, and ifelse() is the function we normally use for recoding.
tbi <- tbi |>
mutate(
1 age_months = age * 12,
2 elderly = ifelse(age >= 65, 1, 0),
3 severity = case_when(
d.motor <= 2 ~ "severe",
d.motor <= 4 ~ "moderate",
TRUE ~ "mild"
)
)
table(tbi$severity)ifelse(test, yes, no) is vectorised, so it evaluates the condition for every row and picks the corresponding value.
case_when() handles more than two categories. Conditions are checked in order, and TRUE ~ ... is the catch-all “everything else”.
mild moderate severe
870 996 293
ifelse() propagates NA, so if the test is NA for a patient, the result is NA as well. That is usually the desired behaviour, but it is worth checking rather than assuming.
Practical 2 uses exactly this pattern to administratively censor survival times at two years:
The functions below are the ones used in the next practicals to describe the data before modelling.
1table(tbi$d.unfav)
2proportions(table(tbi$d.unfav)) * 100
3table(tbi$d.pupil, tbi$d.unfav)
4summary(tbi[, c("age", "d.motor", "glucose")])
5colSums(is.na(tbi[, c("d.unfav", "age", "d.pupil", "cause")]))NAs.
0 1
1308 851
0 1
60.5836 39.4164
0 1
both reactive 1008 422
no reactive pupils 98 229
one reactive 132 147
age d.motor glucose
Min. :14.00 Min. :1.000 Min. : 0.5556
1st Qu.:22.00 1st Qu.:3.000 1st Qu.: 6.7000
Median :30.00 Median :4.000 Median : 8.2000
Mean :33.21 Mean :3.991 Mean : 8.9078
3rd Qu.:43.00 3rd Qu.:5.000 3rd Qu.:10.4000
Max. :79.00 Max. :6.000 Max. :41.4000
NA's :64
d.unfav age d.pupil cause
0 0 123 0
To compute something within groups, use by() or tapply():
1tapply(tbi$age, tbi$trial, mean)
2by(tbi$d.unfav, tbi$trial, \(x) round(proportions(table(x)) * 100, 1))\(x) is an anonymous function, that is, a function defined on the spot without a name. We return to this below. This line also appears in Practical 3.
Tirilazad International Tirilazad US
33.61270 32.78002
tbi$trial: Tirilazad International
x
0 1
59.2 40.8
------------------------------------------------------------
tbi$trial: Tirilazad US
x
0 1
62.1 37.9
A function bundles a set of operations so that they can be reused. We have used plenty of them already, and now we will write some ourselves.
1say_hello <- function() {
print("Hello world!")
}
say_hello()
2odds <- function(p) {
3 return(p / (1 - p))
}
odds(0.25)
4odds(c(0.1, 0.5, 0.9))return() states explicitly what comes out. Without it, R returns the last expression that was evaluated, but writing it out is clearer.
[1] "Hello world!"
[1] 0.3333333
[1] 0.1111111 1.0000000 9.0000000
Arguments can be given default values, which makes a function easier to call while keeping it flexible.
1describe <- function(x, digits = 1, na.rm = TRUE) {
c(mean = round(mean(x, na.rm = na.rm), digits),
sd = round(sd(x, na.rm = na.rm), digits))
}
2describe(tbi$age)
3describe(tbi$age, digits = 3)x is required; digits and na.rm have defaults.
mean sd
33.2 13.6
mean sd
33.211 13.578
if and else let a function change its behaviour depending on its input. This is how the helper functions in Practical 3 deal with two different kinds of model object.
1risk_group <- function(p, threshold = 0.5) {
2 if (any(is.na(p))) {
warning("Missing predictions were found.")
}
3 if (length(threshold) != 1) {
stop("`threshold` must be a single number.")
}
4 ifelse(p >= threshold, "high", "low")
}
risk_group(c(0.2, 0.8, 0.55))warning() prints a message but lets the function continue.
stop() aborts with an error. Checking the input at the start of a function usually saves time later on.
if handles a single TRUE or FALSE and decides which code is run, whereas ifelse() works element by element on a vector and returns a vector.
[1] "low" "high" "high"
A function can return only one object, but that object can be a list.
1cal_metrics <- function(fit, newdata = NULL) {
2 if (is.null(newdata)) {
lp <- fit[["linear.predictors"]]
y <- fit[["y"]]
} else {
3 lp <- predict(fit, newdata = newdata, type = "link")
y <- newdata[["d.unfav"]]
}
4 cal_int <- coef(glm(y ~ offset(lp), family = binomial))[[1]]
cal_slope <- coef(glm(y ~ lp, family = binomial))[[2]]
5 return(list(intercept = cal_int, slope = cal_slope))
}
6out <- cal_metrics(fit)
out
7out[["slope"]]newdata = NULL is the idiom for an optional argument: “use the development data unless I give you something else”.
is.null() tests whether the argument was supplied, and the two branches take the values from different places.
predict() with newdata applies the fitted model to patients that were not used to fit it, which is what external validation comes down to.
newdata, so it evaluates the model on its own development data.
[[, as always.
$intercept
[1] 2.506454e-11
$slope
[1] 1
[1] 1
The function calmetrics() in Practical 3 has the same structure, with the same NULL defaults, the same conditional, and a list as output.
A function that is used only once does not need a name. The \(x) syntax is shorthand for function(x).
A for-loop repeats a block of code once for each element of a sequence.
1for (i in 1:3) {
cat("iteration", i, "\n")
}
2for (v in c("age", "glucose")) {
cat(v, "has mean", round(mean(tbi[[v]], na.rm = TRUE), 2), "\n")
}i takes the values 1, 2, 3 in turn.
[[. This works with [[ but not with $, which is why we spent some time on the difference earlier.
iteration 1
iteration 2
iteration 3
age has mean 33.21
glucose has mean 8.91
A loop that only prints is not very useful, since we normally want to keep the results. There are two common patterns for doing this.
vars <- c("age", "d.motor", "glucose")
1means <- numeric(length(vars))
names(means) <- vars
2for (i in seq_along(vars)) {
means[i] <- mean(tbi[[vars[i]]], na.rm = TRUE)
}
round(means, 2)seq_along(vars) gives 1:length(vars), and is safer because it still behaves correctly if vars happens to be empty.
age d.motor glucose
33.21 3.99 8.91
The second pattern grows a data frame row by row with rbind(). This is slower, but it is easy to read, and Practical 3 uses it to collect the predictions of four different models.
1results <- NULL
for (v in vars) {
2 fit_v <- glm(tbi$d.unfav ~ tbi[[v]], family = binomial)
3 results <- rbind(results,
data.frame(variable = v,
coefficient = round(coef(fit_v)[[2]], 4),
aic = round(AIC(fit_v), 1)))
}
results variable coefficient aic
1 age 0.0316 2806.0
2 d.motor -0.6887 2622.7
3 glucose 0.1399 2704.6
sapply() does the same as a for-loop, but in a single line, and returns the results directly.
1sapply(vars, \(v) mean(tbi[[v]], na.rm = TRUE)) |> round(2)
2sapply(tbi[, vars], class)
3lapply(vars, \(v) summary(tbi[[v]]))[[1]]vars and simplify the result into a named vector. Compare this with the six-line loop above.
sapply() over a data frame applies the function to each column.
lapply() does the same, but always returns a list without simplifying it. This is useful when the results do not all have the same shape.
age d.motor glucose
33.21 3.99 8.91
age d.motor glucose
"integer" "integer" "numeric"
Min. 1st Qu. Median Mean 3rd Qu. Max.
14.00 22.00 30.00 33.21 43.00 79.00
sapply()?
Both do the same job. sapply() is convenient when each iteration produces one value and the iterations are independent of each other. A for-loop is easier to follow when each step depends on the previous one, or when the body of the loop is long.
The code below is the pattern behind bootstrap validation, and a shortened version of what Practical 3 does to estimate a uniform shrinkage factor. It combines most of what we have covered so far.
1set.seed(1)
2dev <- tbi[!is.na(tbi$d.pupil), ]
fit_dev <- glm(d.unfav ~ age + factor(d.motor),
data = dev, family = binomial)
3b <- 100
4slopes <- sapply(1:b, \(i) {
5 smp <- dev[sample(nrow(dev), replace = TRUE), ]
6 fit_b <- glm(d.unfav ~ age + factor(d.motor),
data = smp, family = binomial)
7 lp <- predict(fit_b, newdata = dev, type = "link")
8 coef(glm(dev$d.unfav ~ lp, family = binomial))[[2]]
})
cat("Mean calibration slope over", b, "bootstraps:",
9 round(mean(slopes), 3), "\n")b repetitions, run the anonymous function and collect its single returned value.
Mean calibration slope over 100 bootstraps: 0.989
{ggplot2}Practicals 2 and 3 present their results graphically, using calibration plots, ROC curves, and effect plots, all of which are made with {ggplot2}. Such a plot always consists of the data, an aesthetic mapping from variables to visual properties, and one or more geometries that are drawn. Layers are added with +.
library(ggplot2)
1ggplot(data = tbi, aes(x = age)) +
2 geom_histogram(binwidth = 5,
fill = "#648FFF",
colour = "white") +
3 labs(x = "Age (yrs)", y = "Number of patients") +
4 theme_bw()age goes on the x-axis.
labs() also takes title and subtitle.
Adding a second variable is a matter of extending the mapping. The plot below is a first calibration plot, of the kind that Practical 2 builds:
1dev$preds <- fit_dev[["fitted.values"]]
2ggplot(dev, aes(x = preds, y = d.unfav)) +
3 geom_abline(linewidth = 1, colour = "black", alpha = 0.33) +
4 geom_point(alpha = 0.2) +
5 geom_smooth(method = "loess", formula = "y ~ x",
colour = "#785EF0", fill = "#785EF0") +
6 coord_cartesian(xlim = c(0, 1), ylim = c(0, 1)) +
labs(x = "Predicted probability", y = "Observed probability") +
theme_bw()ggplot() can find them.
alpha = 0.33).
Two further features that we need are mapping a variable to colour and splitting a plot into panels.
1ggplot(dev, aes(x = age, y = preds, colour = factor(d.unfav))) +
geom_point(alpha = 0.4) +
2 scale_colour_manual(
values = c("#648FFF", "#FFB000"),
labels = c("Favourable", "Unfavourable")
) +
3 facet_wrap(~ trial) +
labs(x = "Age (yrs)", y = "Predicted probability") +
theme_bw() +
4 theme(legend.position = "bottom", legend.title = element_blank())d.unfav to colour splits the points into two coloured groups. The variable has to be a factor, otherwise {ggplot2} draws a continuous colour scale.
facet_wrap() draws one panel per level of a variable, using a one-sided formula.
Model output usually needs some tidying before it goes into a report. The functions below are used throughout Practicals 2 and 3.
auc_value <- 0.7654321
1round(auc_value, 3)
2format(round(0.7, 3), nsmall = 3)
3paste0("AUC = ", round(auc_value, 3))
4cat("AUC:", round(auc_value, 3), "\n")
5cat("Intercept:", 0.001, "\nSlope:", 1.002, "\n")
6sprintf("AUC = %.3f (95%% CI %.3f-%.3f)", 0.765, 0.74, 0.79)round() drops trailing zeros; format(..., nsmall = 3) forces them back, so that columns line up.
paste0() pastes strings together without a separator, whereas paste() uses a space.
cat() prints without quotes or index markers, which is what we want for a readable message. The \n stands for a line break.
sprintf() gives precise control over formatting. %.3f means “a number with three decimals”; %% prints a literal percent sign.
[1] 0.765
[1] "0.700"
[1] "AUC = 0.765"
AUC: 0.765
Intercept: 0.001
Slope: 1.002
[1] "AUC = 0.765 (95% CI 0.740-0.790)"
print() shows the object as R stores it, including quotes and [1] markers, whereas cat() prints the text as it should be read. In general, we use cat() for messages and print() for objects.
This last section combines the previous parts into one short analysis, and stops where Practical 2 begins. Work through it line by line and check that every step is clear.
# ---- 1. Start clean and set a seed -------------------------------
1rm(list = ls())
set.seed(1)
tbi <- rio::import("I:/onderwijs/PREDICT (voormalig PINT)/2026 Najaar/R practica/TBI_data.txt")
# ---- 2. Select outcome and predictors ----------------------------
2outcome <- "d.unfav"
predictors <- c("age", "d.motor", "d.pupil", "cause")
3analysis <- tbi[, c(outcome, predictors)]
str(analysis)[[ and [ accept character vectors.
'data.frame': 2159 obs. of 5 variables:
$ d.unfav: int 0 0 0 0 0 0 0 0 0 0 ...
$ age : int 14 14 14 14 14 14 14 14 14 14 ...
$ d.motor: int 5 4 4 4 5 3 5 5 4 5 ...
$ d.pupil: chr "both reactive" "both reactive" "both reactive" "both reactive" ...
$ cause : chr "Motorbike" "Motorbike" "domestic/fall" "Motorbike" ...
# ---- 3. Inspect the outcome --------------------------------------
1table(analysis[[outcome]])
round(proportions(table(analysis[[outcome]])) * 100, 1)
# ---- 4. Inspect missingness --------------------------------------
2colSums(is.na(analysis))d.pupil is incomplete. Practical 2 imputes it; here we simply note it.
0 1
1308 851
0 1
60.6 39.4
d.unfav age d.motor d.pupil cause
0 0 0 123 0
# ---- 5. Declare categorical variables as factors ------------------
1analysis[["d.motor"]] <- as.factor(analysis[["d.motor"]])
analysis[["d.pupil"]] <- factor(
analysis[["d.pupil"]],
levels = c("no reactive pupils", "one reactive", "both reactive"),
2 labels = 0:2)
analysis[["cause"]] <- factor(analysis[["cause"]])
3sapply(analysis, class) d.unfav age d.motor d.pupil cause
"integer" "integer" "factor" "factor" "factor"
# ---- 6. Fit the model --------------------------------------------
1f <- as.formula(paste(outcome, "~", paste(predictors, collapse = " + ")))
f
2fit <- glm(f, data = analysis, family = binomial)
summary(fit)paste(..., collapse = " + ") joins the predictors with plus signs, and as.formula() turns the resulting string into a formula object.
d.unfav ~ age + d.motor + d.pupil + cause
Call:
glm(formula = f, family = binomial, data = analysis)
Coefficients:
Estimate Std. Error z value Pr(>|z|)
(Intercept) -0.347931 0.664542 -0.524 0.6006
age 0.037503 0.004037 9.289 < 2e-16 ***
d.motor2 0.648468 0.621264 1.044 0.2966
d.motor3 0.057519 0.614843 0.094 0.9255
d.motor4 -0.660139 0.609902 -1.082 0.2791
d.motor5 -1.304401 0.610827 -2.135 0.0327 *
d.motor6 -1.628588 0.680168 -2.394 0.0166 *
d.pupil1 -0.733449 0.185156 -3.961 7.46e-05 ***
d.pupil2 -1.285761 0.148476 -8.660 < 2e-16 ***
causedomestic/fall 0.326742 0.247825 1.318 0.1874
causeMotorbike 0.162727 0.245626 0.662 0.5077
causeother 0.435268 0.245729 1.771 0.0765 .
causeRoad traffic accident 0.183664 0.231363 0.794 0.4273
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
(Dispersion parameter for binomial family taken to be 1)
Null deviance: 2726.7 on 2035 degrees of freedom
Residual deviance: 2251.5 on 2023 degrees of freedom
(123 observations deleted due to missingness)
AIC: 2277.5
Number of Fisher Scoring iterations: 4
# ---- 7. Reach into the fit ---------------------------------------
1length(coef(fit))
2length(fit[["y"]])
3round(exp(coef(fit)[["age"]]), 3)
analysis[["preds"]] <- NA
4analysis[["preds"]][!is.na(tbi$d.pupil)] <- fit[["fitted.values"]]
cat("Number of coefficients:", length(coef(fit)),
"\nPatients used:", length(fit[["y"]]),
"\nMean predicted risk:", round(mean(fit[["fitted.values"]]), 3),
5 "\nObserved risk:", round(mean(fit[["y"]]), 3), "\n")d.pupil were silently dropped.
[1] 13
[1] 2036
[1] 1.038
Number of coefficients: 13
Patients used: 2036
Mean predicted risk: 0.392
Observed risk: 0.392
We now have a fitted model, together with its coefficients, predicted probabilities, and linear predictors, and we know how to get each of these out of the fit object. Practical 2 starts at this point and continues with the next questions. Is age linearly related to the outcome? What should we do about the patients that were dropped? And how well does this model discriminate and calibrate?
Errors are a normal part of working in R. The table below lists a few common ones, together with what to check first.
| Problem | First thing to try |
|---|---|
| “could not find function” | The package is not loaded. Use library() or pkg::fun(). |
| “object not found” | A typo, or the object was never created. Check ls(). |
| “undefined columns selected” | You are subsetting a column that does not exist. Check names(). |
Unexpected NAs |
A coercion failed, or na.rm = TRUE is missing. |
| The result has the wrong shape | Check with class(), dim(), and str(). Usually [ was used where [[ was needed. |
| A factor behaves oddly | Check levels(). Convert with as.numeric(as.character(x)). |
| No idea what an object is | str(x, max.level = 1). |
Apart from that, ?glm opens the help page of a function and ??calibration searches all help pages. The Examples section at the bottom of a help page is often the quickest way to understand an unfamiliar function.
We have now covered the building blocks of R that we need for prediction modelling: packages and reproducibility, vectors and types, subsetting with [, [[, and $, factors and reference categories, lists and the structure of a model fit object, formulas, data wrangling, functions with default arguments and conditionals, loops and the apply family, a first bootstrap, plotting with {ggplot2}, and formatting results.
None of this was statistics, but it is what the next two practicals build on. From Practical 2 onwards, R itself should no longer be the difficult part.
Before the next practical, make sure that {pacman} is installed, and have a look at the tutorial on how to create a prediction model: https://github.com/CHMMaas/TutorialPredictionModel
This practical was developed on:
R version 4.4.0 (2024-04-24 ucrt)
Platform: x86_64-w64-mingw32/x64
Running under: Windows 11 x64 (build 22631)
Matrix products: default
locale:
[1] LC_COLLATE=Dutch_Netherlands.utf8 LC_CTYPE=Dutch_Netherlands.utf8
[3] LC_MONETARY=Dutch_Netherlands.utf8 LC_NUMERIC=C
[5] LC_TIME=Dutch_Netherlands.utf8
time zone: Europe/Amsterdam
tzcode source: internal
attached base packages:
[1] stats graphics grDevices utils datasets methods base
other attached packages:
[1] ggplot2_4.0.3 dplyr_1.1.4 stringr_1.6.0
loaded via a namespace (and not attached):
[1] Matrix_1.7-0 gtable_0.3.6 jsonlite_2.0.0 compiler_4.4.0
[5] tidyselect_1.2.1 splines_4.4.0 scales_1.4.0 yaml_2.3.12
[9] fastmap_1.2.0 lattice_0.22-6 mime_0.13 R6_2.6.1
[13] labeling_0.4.3 generics_0.1.4 knitr_1.51 htmlwidgets_1.6.4
[17] tibble_3.2.1 pillar_1.11.1 RColorBrewer_1.1-3 R.utils_2.13.0
[21] rlang_1.2.0 stringi_1.8.7 xfun_0.57 S7_0.2.2
[25] otel_0.2.0 cli_3.6.5 mgcv_1.9-1 withr_3.0.2
[29] magrittr_2.0.3 rio_1.2.4 digest_0.6.37 grid_4.4.0
[33] rstudioapi_0.18.0 nlme_3.1-164 lifecycle_1.0.5 R.methodsS3_1.8.2
[37] R.oo_1.27.1 vctrs_0.7.3 evaluate_1.0.5 glue_1.8.0
[41] data.table_1.17.0 farver_2.1.2 rmarkdown_2.31 tools_4.4.0
[45] pkgconfig_2.0.3 htmltools_0.5.8.1