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.

Get started: correct machine learning made easy

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:

  1. Get started (this tutorial, about 30–40 minutes): building a model and estimating its accuracy correctly.
  2. Evaluating and interpreting your model (about 20–30 minutes): discrimination, calibration, relevant variables, and predicting new patients.
  3. Missing data (about 20–30 minutes): multiple imputation.
  4. Continuous and survival outcomes, regression to the mean, and multisite data (about 30–40 minutes).

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:

  1. Fit a prediction model with glmnet_fit and apply it with glmnet_predict (or just predict).
  2. Understand why evaluating a model with the same patients used to build it gives results that are too good to be true.
  3. Estimate the accuracy of a model correctly with cv (cross-validation).
  4. Include categorical variables with data.frame2glmnet.matrix.

All the code runs in a few seconds, so we encourage you to run it yourself as you read.

New to R?

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):

install.packages("easy.glmnet")

Every time you open R, load the package with:

library(easy.glmnet)

Objects. R stores values in objects with = (you may also see <-, which does the same). Typing the name of an object shows its value:

a = 5
a * 2
#> [1] 10

Vectors are lists of values, created with c():

ages = c(34, 51, 28)
mean(ages)
#> [1] 37.66667

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:

add_ten = function (x) {
  x + 10
}
add_ten(5)
#> [1] 15

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 data

The package includes two simulated datasets:

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 44

Our 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.

Step 1: A first prediction model

Preparing the data

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$relapse

Some steps of this tutorial involve randomness. To obtain exactly the same results as shown here, we fix the “seed” of the random number generator:

set.seed(1)

A standard logistic regression

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 occurred

R 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      1

simple.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:

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.7203496

In 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

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.9846831

Again, 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.8188828

The 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.

Step 2: Cross-validation

The idea

Cross-validation splits the patients into several groups called folds (usually 10). Then, for each fold:

  1. It builds a model using the patients of all the other folds (the training set).
  2. It applies this model to the patients of this fold (the test set).
         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.

Doing it with cv

The 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 = 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 = cv(x, y, family = "binomial", fit_fun, predict_fun, verbose = FALSE)

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      1

Let’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.904

The 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.

A note on randomness

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.

Step 3: Data leakage

Cross-validation can also be optimistic if used wrongly

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.904

The 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.7856688

Quite 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.

The solution

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.

Step 4: Categorical variables

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    1

For 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.904

The 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.

Summary

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.

References

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.