The hardware and bandwidth for this mirror is donated by dogado GmbH, the Webhosting and Full Service-Cloud Provider. Check out our Wordpress Tutorial.
If you wish to report a bug, or if you are interested in having us mirror your free-software or open-source project, please feel free to contact us at mirror[@]dogado.de.
This tutorial shows you how to build a prediction model and, above all, how to find out how well it will really work in new patients. You do not need any previous knowledge of machine learning, and very little of R.
This is the first of four tutorials:
We recommend following all of them in order, but feel free to go directly to the one you need.
In this tutorial, you will learn to:
glmnet_fit and apply it
with glmnet_predict (or just predict).cv
(cross-validation).data.frame2glmnet.matrix.All the code runs in a few seconds, so we encourage you to run it yourself as you read.
If you have used R before, you can skip this section.
Installing. Install R from https://cran.r-project.org and, if you want a friendlier interface, RStudio from https://posit.co. Then install this package by typing the following in the R console (you only need to do this once):
Every time you open R, load the package with:
Objects. R stores values in objects with
= (you may also see <-, which does the
same). Typing the name of an object shows its value:
Vectors are lists of values, created with
c():
Data frames are tables, where each row is an
observation (e.g., a patient) and each column is a variable. You can get
a column with $, and select rows and columns with
[rows, columns]:
df = data.frame(age = c(34, 51, 28), sex = c("F", "M", "F"))
df$age
#> [1] 34 51 28
df[1:2, ] # rows 1 and 2, all columns
#> age sex
#> 1 34 F
#> 2 51 M
df[, "sex"] # all rows, column "sex"
#> [1] "F" "M" "F"Functions take some inputs (arguments) and return an
output. You can write your own with function. The last line
is what the function returns:
This is important because, as you will see, to cross-validate a model you will write two small functions.
Finally, to get help about any function, type ? followed
by its name, e.g., ?mean or ?glmnet_fit.
The package includes two simulated datasets:
patients: 100 patients who were followed to see whether
they relapsed. For each patient, we have 40 numeric variables
(X1 to X40), 3 categorical variables
(C1 to C3), and whether they relapsed
(relapse: 1 = yes, 0 = no).new_patients: 900 other patients from the same
population.dim(patients) # number of rows (patients) and columns
#> [1] 100 52
patients[1:5, c(1:3, 41:47)] # first 5 patients, some of the columns
#> X1 X2 X3 C1 C2 C3 L1 L2 L3 site
#> 1 21.86773 30.67483 20.56925 C C C 15.8 25.0 33.0 Hospital D
#> 2 25.91822 30.55966 15.38873 C B A 10.0 19.3 16.5 Hospital D
#> 3 20.82186 20.64611 33.09850 C B B 18.5 31.8 29.9 Hospital D
#> 4 32.97640 26.05366 27.59635 B A B 24.4 29.5 34.0 Hospital B
#> 5 26.64754 25.34698 24.72075 A A C 13.3 23.4 14.3 Hospital D
table(patients$relapse) # how many relapsed
#>
#> 0 1
#> 56 44Our goal is to use the data of the 100 patients to build a model that
predicts relapse in future patients (new_patients), and to
know how accurate these predictions will be.
In a real study, you would not have new_patients: future
patients have not arrived yet! Here we have them because the data are
simulated, and we will use them as a “crystal ball” to check which
methods tell us the truth about the accuracy of our models.
Most prediction functions need the predictors as a numeric matrix (a table with only numbers) and the outcome as a vector (a single row or column of elements). Let’s start with the 40 numeric variables:
x = as.matrix(patients[, 1:40]) # columns X1, X2, ..., X40
y = patients$relapse
x_new = as.matrix(new_patients[, 1:40])
y_new = new_patients$relapseSome steps of this tutorial involve randomness. To obtain exactly the same results as shown here, we fix the “seed” of the random number generator:
Many researchers would start with a standard logistic regression including all the variables:
m_glm = glm(y ~ ., data = data.frame(x), family = "binomial")
#> Warning: glm.fit: algorithm did not converge
#> Warning: glm.fit: fitted probabilities numerically 0 or 1 occurredR warns us that the fitted probabilities are 0 or 1. Keep this in
mind, it is a first sign of trouble. Let’s see how well the model
predicts relapse in our patients. The function predict
returns, for each patient, the probability of relapse, and
round converts it into a prediction: 1 (relapse) if the
probability is higher than 0.5, or 0 (no relapse) otherwise:
y.pred = round(predict(m_glm, type = "response"))
simple.accuracy(y, y.pred, "bac")
#> bac bac.lwr bac.upr se se.lwr se.upr sp sp.lwr sp.upr
#> 1 1 0.9635 1 1 0.9448164 1 1 0.9563268 1simple.accuracy compares the actual outcomes
(y) with the predictions (y.pred). Here, it
returns three numbers, each with the lower (.lwr) and upper (.upr)
limits of its 95% confidence interval:
se (sensitivity): the proportion of patients who
relapsed that the model correctly predicted would relapse. 1 (i.e.,
100%) means that the model correctly predicted all the patients who
relapsed.sp (specificity): the proportion of patients who did
not relapse that the model correctly predicted would not relapse. 1
(i.e., 100%) means that the model correctly predicted all the patients
who did not relapse.bac (balanced accuracy): the mean of the two.A perfect model! Too good to be true? Let’s use our crystal ball and apply the model to the new patients:
y_new.pred = round(predict(m_glm, data.frame(x_new), type = "response"))
simple.accuracy(y_new, y_new.pred, "bac")
#> bac bac.lwr bac.upr se se.lwr se.upr sp sp.lwr
#> 1 0.6777762 0.6465 0.7075 0.6770601 0.632779 0.7190669 0.6784922 0.6343522
#> sp.upr
#> 1 0.7203496In new patients, the balanced accuracy drops quite a bit. What happened?
The model has memorized the 100 patients instead of learning general rules. With 40 variables and only 100 patients, the model can adjust to the random particularities of most or all patients, which will not be repeated in new patients. This is called overfitting. It is like a student who memorizes the answers of a mock exam: they get a perfect score in that exam, but not in the real one.
A lasso model is a type of regression designed to reduce overfitting:
it makes the coefficients smaller (“shrinks” them) and can remove the
variables that do not seem useful. glmnet_fit fits a lasso
model in one line (Solanes et al., 2022):
m_lasso = glmnet_fit(x, y, family = "binomial")
#> Warning in glmnet_fit(x, y, family = "binomial"): In easy.glmnet 1.2, the
#> default 'type.measure' for "binomial" changed from "class" to glmnet "default"
#> ("deviance"). Use type.measure = "class" to reproduce the results of previous
#> versions. This warning is shown once per session.(If you see a warning about type.measure, it only
informs users of previous versions of the package that a default setting
has changed; you can ignore it.)
To apply it to some patients, use glmnet_predict (or
just predict), which returns the probabilities of
relapse:
y.pred = round(predict(m_lasso, x))
simple.accuracy(y, y.pred, "bac")
#> bac bac.lwr bac.upr se se.lwr se.upr sp sp.lwr
#> 1 0.950487 0.88875 0.978 0.9545455 0.8620847 0.9904354 0.9464286 0.8638995
#> sp.upr
#> 1 0.9846831Again, in the same patients used to build the model, the balanced accuracy is very high . Let’s look at the new patients:
y_new.pred = round(predict(m_lasso, x_new))
simple.accuracy(y_new, y_new.pred, "bac")
#> bac bac.lwr bac.upr se se.lwr se.upr sp sp.lwr
#> 1 0.7399049 0.7105 0.76725 0.6971047 0.6534387 0.7382388 0.7827051 0.7429126
#> sp.upr
#> 1 0.8188828The lasso model may work some better than the standard logistic regression in new patients, but its accuracy is still much lower than it seemed. Thus, whatever the method (Radua & Koutsouleris, 2024):
Lesson 1: Never evaluate a model with the same patients that you used to build it.
But in a real study, we have no crystal ball. How can we know the accuracy of a model in new patients if we only have the patients used to build it? The answer is cross-validation.
Cross-validation splits the patients into several groups called folds (usually 10). Then, for each fold:
Fold 1 Fold 2 Fold 3 ... Fold 10
Round 1: test train train ... train
Round 2: train test train ... train
Round 3: train train test ... train
...
Round 10: train train train ... test
At the end, each patient has a prediction from a model that never saw them, just like a new patient. Comparing these predictions with the actual outcomes tells us how well the model works in “new” patients.
cvThe function cv does all the work (dividing the patients
into folds, building the models, applying them, and collecting the
predictions), but it needs you to tell it two things, as two small
functions:
fit_fun: how to build a model from the training set. It
receives the predictors and outcomes of the training set and must return
the model.predict_fun: how to apply a model to the test set. It
receives the model and the predictors of the test set and must return
the predictions.fit_fun = function (x_training, y_training) {
glmnet_fit(x_training, y_training, family = "binomial")
}
predict_fun = function (m, x_test) {
round(predict(m, x_test))
}Note that these functions do exactly what we did before with
glmnet_fit and glmnet_predict (or just
predict), but with the training and test sets of each fold.
Now we can run the cross-validation:
res$predictions contains, for each patient, the fold,
the actual outcome and the prediction:
head(res$predictions)
#> fold y y.pred
#> 1 4 0 0
#> 2 7 0 0
#> 3 5 1 0
#> 4 9 0 0
#> 5 7 0 0
#> 6 9 1 1Let’s see the accuracy (we just have to print the cv
object):
res
#> 10-fold CV of 100 binomial outcomes:
#> bac bac.lwr bac.upr se se.lwr se.upr sp sp.lwr sp.upr
#> 1 0.718 0.624 0.796 0.614 0.466 0.747 0.821 0.706 0.904The cross-validation estimates a balanced accuracy very close to the one that the lasso model actually achieved in the new patients. Unlike the evaluation with the same patients, the cross-validation tells the truth, even without a crystal ball.
cv assigns the patients to the folds randomly (though
ensuring that each fold has a similar proportion of patients who
relapsed). With another seed, the folds would be different, and the
estimate would change a bit, especially in small samples like this one.
This is why we used set.seed: to make the results
reproducible.
Imagine that, before the cross-validation, we select the variables
with the lasso model that we fitted in Step 1 with all the patients
(m_lasso; recall that the lasso sets some coefficients
exactly to zero, so it uses only some of the variables), and then we
cross-validate a model that uses only these variables. It seems
reasonable: we only keep the variables that seem useful. Let’s try:
selected = names(m_lasso$beta) # variables selected by the lasso of Step 1
res = cv(x[, selected], y, family = "binomial", fit_fun, predict_fun, verbose = FALSE)
res
#> 10-fold CV of 100 binomial outcomes:
#> bac bac.lwr bac.upr se se.lwr se.upr sp sp.lwr sp.upr
#> 1 0.797 0.707 0.864 0.773 0.634 0.877 0.821 0.706 0.904The cross-validation estimates a higher balanced accuracy than in step 2! But let’s use our crystal ball: we build the model with these variables and all the patients, and apply it to the new patients:
m_leak = glmnet_fit(x[, selected], y, family = "binomial")
y_new.pred = round(predict(m_leak, x_new[, selected]))
simple.accuracy(y_new, c(y_new.pred), "bac")
#> bac bac.lwr bac.upr se se.lwr se.upr sp sp.lwr
#> 1 0.7266209 0.6965 0.75475 0.7060134 0.6626511 0.7467295 0.7472284 0.7056266
#> sp.upr
#> 1 0.7856688Quite less. The cross-validation was too optimistic. Why? Because the variables were selected using all the patients, including those that later played the role of test set! In other words, the selection “saw” the test set, so these patients were no longer like new patients. This is called data leakage: information from the test set leaks into the models.
There is a simple solution (Radua & Koutsouleris, 2024):
Lesson 2: Every step that learns something from the data is part of the model. Thus, it must be inside
fit_fun, and must use only the training set.
So far, we have only used the numeric variables. But our patients
also have three categorical variables (C1, C2,
and C3), with values A, B, or
C. glmnet_fit needs a numeric matrix, so we
must first convert each categorical variable into several numeric
variables (called “dummy” variables), one for each value.
data.frame2glmnet.matrix does it for you:
df = patients[, 1:43] # all predictors
df2mat = data.frame2glmnet.matrix_fit(df)
x_all = data.frame2glmnet.matrix(df2mat, df)
x_all[1:5, 41:49]
#> C1:A C1:B C1:C C2:A C2:B C2:C C3:A C3:B C3:C
#> [1,] 0 0 1 0 0 1 0 0 1
#> [2,] 0 0 1 0 1 0 1 0 0
#> [3,] 0 0 1 0 1 0 0 1 0
#> [4,] 0 1 0 1 0 0 0 1 0
#> [5,] 1 0 0 1 0 0 0 0 1For example, C1:A is 1 if the patient has
C1 = A, and 0 otherwise.
Note that there are two functions.
data.frame2glmnet.matrix_fit learns how to
convert the data (which variables are categorical, which values they can
take, etc.), and data.frame2glmnet.matrix
applies this conversion to some data. Following Lesson
2, the conversion must be learned inside fit_fun, using
only the training set. Converting the data learns much less than
selecting variables, but it is a good habit, and other steps learn a lot
(e.g., imputing missing values, as you will see in a subsequent
tutorial).
So, the model now has two parts: the conversion and the lasso.
fit_fun can return both in a list, and
predict_fun can then use both:
fit_fun = function (df_training, y_training) {
df2mat = data.frame2glmnet.matrix_fit(df_training)
x_training = data.frame2glmnet.matrix(df2mat, df_training)
list(
df2mat = df2mat,
lasso = glmnet_fit(x_training, y_training, family = "binomial")
)
}
predict_fun = function (m, df_test) {
x_test = data.frame2glmnet.matrix(m$df2mat, df_test)
round(predict(m$lasso, x_test))
}
df = patients[, 1:43] # all predictors
res = cv(df, y, family = "binomial", fit_fun, predict_fun, verbose = FALSE)
res
#> 10-fold CV of 100 binomial outcomes:
#> bac bac.lwr bac.upr se se.lwr se.upr sp sp.lwr sp.upr
#> 1 0.718 0.624 0.796 0.614 0.466 0.747 0.821 0.706 0.904The results are the same as without the categorical variables because, in these data, they are unrelated to relapse, so the lasso does not select them.
cv) estimates how well the model will
work in new patients. You only need to write fit_fun (how
to build the model) and predict_fun (how to apply it).data.frame2glmnet.matrix.fit_fun. Otherwise, the cross-validation will be too
optimistic (Lesson 2).Both lessons are part of rule 4 of the ten simple rules for using machine learning in mental health research (Radua & Koutsouleris, 2024), which apply to other fields too. We recommend reading them all.
In the next tutorial, Evaluating and interpreting your
model (vignette("evaluating-and-interpreting")), you
will learn other ways to measure accuracy and how to interpret your
models.
Radua, J., Koutsouleris, N. (2024) Ten simple rules for using machine learning in mental health research. Biological Psychiatry, 96, 511–513, doi: 10.1016/j.biopsych.2023.11.012.
Solanes, A., Mezquida, G., Janssen, J., Amoretti, S., Lobo, A., Gonzalez-Pinto, A., Arango, C., Vieta, E., Castro-Fornieles, J., Berge, D., Albacete, A., Gine, E., Parellada, M., Bernardo, M.; PEPs group (collaborators); Pomarol-Clotet, E., Radua, J. (2022) Combining MRI and clinical data to detect high relapse risk after the first episode of psychosis. Schizophrenia, 8, 100, doi: 10.1038/s41537-022-00309-w.
These binaries (installable software) and packages are in development.
They may not be fully stable and should be used with caution. We make no claims about them.
Health stats visible at Monitor.