Medical statistics / Prediction models

How to Build a Medical Prediction Model: LASSO, Random Forest and XGBoost, Nomograms and External Validation

Following TRIPOD+AI and PROBAST+AI, this page gives decision criteria for each modelling step in clinical prediction model papers and bioinformatics papers that "select feature genes with machine learning": how to calculate sample size, why the LASSO variable set changes between runs, when to use random forest or XGBoost, how to tune and validate without data leakage, code for calibration, DCA, nomograms and survival models, and the objections reviewers raise most often.

Short answer

Build a medical prediction model in this order: define the prediction time point and outcome, then calculate the sample size with the Riley method (R package pmsampsize). Keep continuous variables continuous, fit LASSO or a pre-specified model, and use logistic or Cox regression with splines as the baseline. Random forest and XGBoost help only when the sample is large or the non-linearity is complex; on most tabular clinical data their discrimination matches logistic regression. Variable selection, scaling, imputation, resampling and tuning must all be repeated inside every cross-validation fold or bootstrap sample. Report internal validation with nested cross-validation or bootstrap optimism correction, then validate externally on temporally or geographically independent data. Report discrimination (AUC or C-index with 95% CI), calibration (calibration intercept, slope and a smoothed calibration curve) and net benefit (DCA). Finally, draw the nomogram with rms, report against the TRIPOD+AI checklist and share the code.

Workflow

Every step that uses the outcome or the data distribution is part of model development and must be redone within each training fold or bootstrap sample during internal validation. Missing one step makes the validation optimistic.

A diagnostic model predicts whether a condition is present now; a prognostic model predicts whether an outcome occurs within a future horizon. They differ in prediction time point and in the variables available. The PROBAST predictor domain asks whether all predictors are available at the time the model is intended to be used, and rates the risk of bias as high when they are not.

StepRecommended practiceRedone in each resample?Common error
Define the prediction questionState the target population, prediction time point, outcome and horizon; use only variables available at the prediction time point—Using post-operative or follow-up information to predict a pre-operative decision
Sample sizeCalculate with pmsampsize from the number of candidate parameters and report it in the methods—Reporting EPV only, or computing EPV from the variables finally selected
Missing dataMultiple imputation or in-model handling; fit the imputation model on training data onlyYesDropping incomplete cases without reporting the proportion
Continuous variablesKeep them continuous; model non-linearity with restricted cubic splines (rcs)—Dichotomising at the median or an "optimal" ROC cut-off
Variable selectionLASSO, or pre-specification from literature and clinical knowledgeYesSelecting on all data and then splitting into training and validation sets
ScalingFit scaling parameters on the training fold and apply them to the test foldYesScaling all data at once
Class imbalanceUsually leave it; if corrected, apply only to training folds and recalibrateYesRunning SMOTE before the split
TuningChoose hyperparameters with inner cross-validationYes (nested)Using one cross-validation both to tune and to report performance
Internal validationBootstrap optimism correction or nested cross-validation—Reporting training AUC only; a random 70:30 split in a modest sample
EvaluationDiscrimination + calibration + DCA, each with 95% CI—AUC only; the Hosmer-Lemeshow test in place of a calibration curve
External validationTemporally or geographically independent data with coefficients and cut-offs held fixed—Refitting coefficients or re-deriving cut-offs in the validation set
ReportingTRIPOD+AI 27 items; share code—Not reporting the tuning grid, random seeds and software versions

Sample size

EPV ≥ 10 (at least 10 events per variable) comes from Peduzzi et al.'s 1996 simulation of regression coefficient bias. It ignores outcome prevalence and expected model performance. van Smeden et al. (2019) showed that EPV is only weakly related to prediction error in new data. Riley et al. (BMJ 2020) proposed a calculation based on target precision; TRIPOD+AI item 10 asks how the study size was arrived at and why it is sufficient, and this method is the calculation currently recommended in the methodological literature.

R: development sample size with pmsampsize (output excerpted from our run)r
library(pmsampsize)   # 1.1.3
# Binary outcome: prevalence 0.253, C statistic 0.80 from a published model (prefer an optimism-adjusted value), 30 candidate parameters
pmsampsize(type = "b", cstatistic = 0.80, parameters = 30, prevalence = 0.253)
#> Criteria 1 (shrinkage >= 0.9)          1136
#> Criteria 2 (R2 optimism <= 0.05)        774
#> Criteria 3 (intercept +/- 0.05)         291
#> Final 1136, 288 events, EPP = 9.58

pmsampsize(type = "b", cstatistic = 0.70, parameters = 30, prevalence = 0.253)
#> Final 2732, 692 events, EPP = 23.04

# Time-to-event: Cox-Snell R2 0.10, 15 parameters, event rate 0.08 per person-year, 3-year horizon, mean follow-up 4 years
pmsampsize(type = "s", csrsquared = 0.10, parameters = 15, rate = 0.08,
           timepoint = 3, meanfup = 4)
#> Final 1274, 408 events, EPP = 27.18

The three Riley criteria

For binary and time-to-event outcomes, take the largest sample size across three criteria: expected shrinkage ≥ 0.9 (overfitting under control); difference between apparent and adjusted Nagelkerke R² ≤ 0.05; overall risk (intercept) estimated within ±0.05. Continuous outcomes have an additional residual-variance criterion. In the table above, the shrinkage criterion is binding in every case.

Count candidate parameters, not selected variables

parameters is the total number of candidate parameters before selection: a 5-level factor counts as 4, a 4-knot spline as 3, and planned interactions count too. If LASSO keeps 12 of 30 parameters, the sample size is still calculated for 30.

Where the C statistic comes from

cstatistic is the C of a published model for the same outcome, ideally externally validated or optimism-adjusted; the function uses it to approximate the Cox-Snell R². The table shows that lowering the expected C from 0.80 to 0.70 raises the requirement from 1,136 to 2,732. Without a reference value, use a conservative, lower C.

Machine learning needs more data

van der Ploeg et al. (2014) simulated from three clinical cohorts: logistic regression reached a stable AUC at roughly 20 to 50 EPV, while random forest, SVM and neural networks needed more than 10 times that, and still showed clear optimism above 200 EPV. pmsampsize gives a lower bound for regression models.

Sample size for external validation

The older rule was at least 100 events and 100 non-events, and 200 of each for a smoothed calibration curve. In the Riley et al. (BMJ 2024) example (prevalence 0.43, C = 0.77), keeping the 95% CI width of the calibration slope within 0.3 alone required 949 patients and 408 events. pmvalsampsize in R and Stata performs the calculation.

When the sample is too small

Report the calculated requirement and the gap, reduce candidate parameters (merge rare categories, drop variables a priori based on literature, use fewer spline knots), use penalised regression, and discuss it. Riley et al. (2021) showed that penalisation is itself unstable in small samples and cannot replace an adequate sample size.

InputsRequired sample sizeEventsEPP (events per parameter)Binding criterion
Prevalence 0.253, C = 0.80, 30 parameters11362889.58Shrinkage ≥ 0.9
Prevalence 0.253, C = 0.70, 30 parameters273269223.04Shrinkage ≥ 0.9
Prevalence 0.10, C = 0.80, 10 parameters762777.62Shrinkage ≥ 0.9
Survival: Cox-Snell R² = 0.10, 15 parameters, event rate 0.08/year, 3 years, mean follow-up 4 years127440827.18Shrinkage ≥ 0.9 and precision of 3-year risk
Our run: pmsampsize 1.1.3, R 4.4, macOS arm64. The first row uses the SUPPORT study data (1,000 patients, 253 in-hospital deaths, 21 candidate variables expanded to 30 parameters); by this calculation the data are too small for a 30-parameter model.

Variable selection

cv.glmnet assigns folds at random. Its help page states that "the results of cv.glmnet are random, since the folds are selected at random" and that users can reduce this randomness by running cv.glmnet many times and averaging the error curves. The table below, from one dataset, shows how large the seed effect is.

Instability is worse in high-dimensional expression data (thousands of genes, tens to hundreds of samples) than in the table above: variables far outnumber samples, highly correlated genes can substitute for each other, and LASSO often keeps one gene from a co-expressed group more or less at random. A single "feature gene list" then means little; report bootstrap selection frequencies, or use the elastic net (alpha between 0 and 1) so that correlated genes enter together.

R: fixed folds, explicit lambda, stability check and averaged error curver
library(glmnet)                                     # version 5.0 in our run
X <- model.matrix(y ~ ., data = dat)[, -1]          # factors become dummy columns; X must be a numeric matrix
y <- dat$y                                          # 0/1

set.seed(2026)
foldid <- sample(rep(1:10, length.out = nrow(X)))   # fix and save the folds; reuse them for reproduction and the supplement
cv <- cv.glmnet(X, y, family = "binomial", foldid = foldid)
# defaults: alpha = 1 (LASSO), standardize = TRUE (internal scaling, coefficients returned on the original scale), type.measure = "deviance"
plot(cv)                                            # left line lambda.min, right line lambda.1se
coef(cv, s = "lambda.min")                          # always pass s; coef(cv) without s returns lambda.1se
coef(cv, s = "lambda.1se")

# Selection stability: one cross-validation per seed for 100 seeds, count how often each variable is selected
sel <- sapply(1:100, function(s) {
  set.seed(s)
  b <- coef(cv.glmnet(X, y, family = "binomial", nfolds = 10), s = "lambda.1se")[-1, 1]
  b != 0
})
sort(rowSums(sel), decreasing = TRUE)               # selection frequency
length(unique(apply(sel, 2, paste, collapse = ""))) # number of distinct variable sets

# Reduce fold randomness: average the CV error curves of 100 fold assignments on one lambda sequence
lam <- glmnet(X, y, family = "binomial")$lambda
cvm <- sapply(1:100, function(s) {
  set.seed(s)
  cv.glmnet(X, y, family = "binomial", lambda = lam,
            foldid = sample(rep(1:10, length.out = nrow(X))))$cvm
})
lam[which.min(rowMeans(cvm))]                       # lambda.min on the averaged curve

Scaling: keep the default standardize = TRUE

glmnet standardises each column internally before penalising and returns coefficients on the original scale, so there is no need to scale beforehand. Turning standardisation off makes the penalty unequal across units and changes the variable set (12 became 8 above). Dummy variables are standardised too, which makes rare categories harder to select; penalty.factor can adjust them separately if needed.

lambda.min or lambda.1se

lambda.min minimises cross-validated error and keeps more variables with slightly better prediction; lambda.1se is the largest lambda within one standard error of the minimum and gives a sparser model. For prediction, the two usually perform similarly on new data; state the choice in the methods and keep it fixed. Switching to 1se to make the list look shorter, or to min to get a target gene selected, is a post hoc choice.

coef(cv) returns lambda.1se by default

The default of argument s in predict.cv.glmnet and coef is "lambda.1se". Many tutorials say "take lambda.min" in the text but call coef(cvfit) without s, which returns the 1se result. Always pass s explicitly.

The seed is not a tuning parameter

set.seed ensures reproducibility, not stability. "Change the seed if the result looks bad" means picking the best-looking of several variable sets, which is data snooping. Instead, fix foldid and publish it in the supplement, average error curves over many fold assignments, or report bootstrap selection frequencies and separate frequently from rarely selected variables.

Problems with "LASSO selection + multivariable regression"

A common pipeline selects variables with LASSO, refits them in an unpenalised logistic or Cox model and drops those with P ≥ 0.05. The second step removes the LASSO shrinkage, so coefficients become too large again; because the data chose the variables, the P values and confidence intervals of the second step lose their nominal meaning (Heinze et al. 2018). For prediction, use the LASSO coefficients directly. If unshrunken coefficients are needed, use relax = TRUE in glmnet (relaxed LASSO) and put both steps inside the bootstrap validation.

Univariable screening before LASSO

"Variables with univariable P < 0.05 enter LASSO" misses predictors masked by confounding and forces the validation to repeat the univariable screen (Sun et al. 1996). One PROBAST (2019) signalling question in the analysis domain asks whether selection of predictors based on univariable analysis was avoided. Pre-specify candidates from literature and clinical knowledge and enter them all into LASSO.

ApproachVariables selectedDistinct variable setsNotes
lambda.min, 100 random seeds19–213lambda.min ranged 0.00566–0.00989
lambda.1se, 100 random seeds10–18 (median 12)7The most frequent set appeared 37 times; lambda.1se ranged 0.0157–0.0399
Error curve averaged over 100 fold assignmentslambda.min 21; lambda.1se 121The result no longer depends on a single seed
100 bootstrap resamples, lambda.1se—98scoma, pafi and alb selected 100 times; hrt and bili 99; crea 98; age 56; urine 51
Same folds, standardize = FALSE8 (12 when TRUE)—Variables with wide ranges (such as urine output) are kept more easily; the composition changes
Our run: SUPPORT data, 1,000 patients, 253 in-hospital deaths, 30 candidate parameters, family = "binomial", 10 folds, default deviance; glmnet 5.0, R 4.4, macOS arm64; 100 cv.glmnet runs took 28.7 s. Missing values were singly imputed with the normal values recommended for SUPPORT, for demonstration only.

Algorithm choice

Christodoulou et al. (2019) reviewed 282 comparisons of logistic regression and machine learning in 71 studies. In the 145 comparisons at low risk of bias, the difference in logit(AUC) was 0.00 (95% CI −0.18 to 0.18); in the 137 at high risk of bias, machine learning was 0.34 higher, which the authors attributed to flawed validation. 79% of studies did not assess calibration.

Order of choice: first fit a logistic or Cox model with splines as the baseline. If the machine learning model does not clearly beat the baseline on both discrimination and calibration in nested cross-validation or external validation, report the baseline. This comparison is the answer when reviewers ask why machine learning was used.

scikit-learn's LogisticRegression applies an L2 penalty with C = 1 by default. When a paper's "logistic regression" baseline is fitted with scikit-learn 1.8 or later, set C = np.inf for an unpenalised model. From 1.8 the penalty argument is deprecated and scheduled for removal in 1.10; L1 is written l1_ratio = 1, and the penalty = "l1" in older tutorials raises a deprecation warning.

In R, predict(rf) without newdata returns out-of-bag (OOB) predictions, which are close to unbiased; predict(rf, newdata = training data) returns predictions from all trees, and the training AUC approaches 1. When reporting "training performance" for a random forest, state which one was used.

MethodWhen it fitsKey hyperparameters (starting points)Probability outputCommon misuse
Logistic / Cox regression (with splines)Tabular clinical data, modest number of variables, a nomogram and interpretable coefficients are needed3–5 spline knotsUsually well calibratedDichotomising continuous variables; stepwise selection
LASSO / elastic netMany candidates or high-dimensional expression data; a sparse model is neededlambda (inner CV), alphaShrunken; calibration slope close to 1Two-step refit that removes shrinkage; seed picking
Random forestComplex interactions and non-linearity, large samplesmtry (R default √p), ntree ≥ 500, minimum node sizeWith minimum node size 1, probabilities are pushed to the extremes and training AUC approaches 1Reporting training performance with predict(rf, newdata = training data)
XGBoostLarge samples, many variables, interactionslearning_rate 0.01–0.1, max_depth 2–4, min_child_weight, subsample, early stoppingUsable with the default logloss objective; distorted after scale_pos_weightFitting a small sample with the defaults learning_rate 0.3 and max_depth 6
SVMModerate samples, continuous features, ranking onlyC, gamma (RBF kernel); scaling is requiredscikit-learn probability=True uses internal 5-fold Platt scaling; predict and predict_proba can disagreeTreating decision_function as a probability on a calibration plot
Hyperparameter names checked against randomForest 4.7, scikit-learn 1.9 and XGBoost 3.x documentation.

Tuning and internal validation

The cross-validation error used to choose hyperparameters is itself optimistic. On simulated data with no real signal, Varma and Simon (2006) found that 18.5% of training sets gave a tuned cross-validation error below 30%; nested cross-validation gave an almost unbiased error.

After nested cross-validation gives the performance estimate, refit the final model once on all data with the same inner tuning procedure. Different outer folds choosing different hyperparameters is expected; it reflects the uncertainty of the pipeline.

In scikit-learn, passing an integer random_state to a cross-validation splitter gives every model the same folds, so folds can be compared one by one; passing an integer to an estimator (such as a random forest) gives every fold the same random state. The documentation recommends integers for splitters and, when comparing algorithms, a RandomState instance or no value for estimators.

rms validate and calibrate default to B = 40, which varies noticeably between seeds; B = 200 or more is common in papers. For stepwise selection, validate(fit, B = 200, bw = TRUE) repeats backward elimination inside each bootstrap sample; LASSO has no such argument, so write the loop yourself as in the code above.

Python: nested cross-validation comparing LASSO-logistic, random forest and XGBoost (checked against scikit-learn 1.9 documentation)python
import numpy as np
from sklearn.model_selection import StratifiedKFold, GridSearchCV, cross_validate
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from xgboost import XGBClassifier

inner = StratifiedKFold(n_splits=5, shuffle=True, random_state=1)   # inner loop: tuning
outer = StratifiedKFold(n_splits=5, shuffle=True, random_state=2)   # outer loop: performance estimate
models = {
    # penalty is deprecated from scikit-learn 1.8; L1 is l1_ratio=1. Before 1.8 use penalty="l1"
    "lasso_lr": (Pipeline([("imp", SimpleImputer(strategy="median")),
                           ("sc", StandardScaler()),
                           ("clf", LogisticRegression(l1_ratio=1, solver="saga", max_iter=5000))]),
                 {"clf__C": np.logspace(-3, 1, 20)}),
    "rf": (Pipeline([("imp", SimpleImputer(strategy="median")),
                     ("clf", RandomForestClassifier(n_estimators=500, random_state=0))]),
           {"clf__max_features": ["sqrt", 0.33, 0.5], "clf__min_samples_leaf": [1, 5, 10, 20]}),
    "xgb": (Pipeline([("clf", XGBClassifier(n_estimators=300, eval_metric="logloss"))]),
            {"clf__learning_rate": [0.03, 0.1], "clf__max_depth": [2, 3, 4],
             "clf__min_child_weight": [1, 5]}),
}
for name, (pipe, grid) in models.items():
    search = GridSearchCV(pipe, grid, cv=inner, scoring="neg_log_loss")  # tune on log loss, which also rewards calibration
    res = cross_validate(search, X, y, cv=outer, scoring=["roc_auc", "neg_brier_score"])
    print(name, res["test_roc_auc"].mean().round(3), res["test_roc_auc"].std().round(3),
          (-res["test_neg_brier_score"]).mean().round(4))
R: bootstrap optimism-corrected AUC for LASSO-logistic (selection redone in every bootstrap sample)r
library(glmnet); library(pROC)
auc_of <- function(p, y) as.numeric(auc(roc(y, p, direction = "<", quiet = TRUE)))  # explicit direction
fit_lasso <- function(X, y) cv.glmnet(X, y, family = "binomial", nfolds = 10)
p_of <- function(cv, X) predict(cv, X, s = "lambda.1se", type = "response")[, 1]

cv0 <- fit_lasso(X, y)
apparent <- auc_of(p_of(cv0, X), y)                 # apparent AUC: same data for fitting and evaluation
set.seed(2026)
opt <- replicate(200, {
  i <- sample(nrow(X), replace = TRUE)
  cvb <- fit_lasso(X[i, ], y[i])                    # selection and lambda choice are redone in every bootstrap sample
  auc_of(p_of(cvb, X[i, ]), y[i]) - auc_of(p_of(cvb, X), y)
})
c(apparent = apparent, optimism = mean(opt), corrected = apparent - mean(opt))
MethodProcedureWhen to useProblems
Random split (e.g. 70:30)One split into training and validation setsVery large samples (Harrell considers it unstable below about 20,000 patients)Results change with the seed; small validation sets give wide CIs; wastes development data
k-fold cross-validationEvery observation is used for validation onceEvaluating one fixed pipelineA single 10-fold run depends on the fold assignment
Repeated k-fold cross-validationRepeat 10-fold several times and averageWhen a stable estimate is neededHarrell notes that about 100 repeats of 10-fold are needed to match the bootstrap
Bootstrap optimism correctionRedo every modelling step in each bootstrap sample, evaluate on the original data, average the optimismRegression models, rms workflows; all data used for developmentSelection and tuning must be redone every time, which is computationally heavy
Nested cross-validationInner loop tunes, outer loop estimates performanceMachine learning models with hyperparameters; comparing algorithmsThe outer estimate describes the whole modelling pipeline, not one final model

Class imbalance

van den Goorbergh et al. (JAMIA 2022) compared random undersampling, random oversampling and SMOTE: none improved AUC, and all caused strong overestimation of minority-class probabilities, i.e. worse calibration. A clinical prediction model outputs risks, so poor calibration misleads decisions directly.

Python: SMOTE applied to training folds only (imbalanced-learn 0.14)python
from imblearn.pipeline import Pipeline          # the imblearn Pipeline; the sklearn Pipeline does not accept samplers
from imblearn.over_sampling import SMOTE
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score

pipe = Pipeline([("sc", StandardScaler()),
                 ("smote", SMOTE(random_state=0)),   # applied to each training fold only; test folds keep the original distribution
                 ("clf", LogisticRegression(max_iter=1000))])
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)
print(cross_val_score(pipe, X, y, cv=cv, scoring="roc_auc").mean())
# Predicted probabilities are inflated after resampling; recalibrate on data with the original distribution before reporting risks (TRIPOD+AI item 13)

AUC does not depend on prevalence

AUC concerns ranking only; its meaning is the same whether the outcome is 5% or 50%. The real issue with imbalance is low sensitivity at a 0.5 threshold, which is solved by choosing a clinically motivated threshold, not by altering the training data.

If you must resample, do it inside training folds

In the imbalanced-learn documentation example, undersampling all data before cross-validation gave a cross-validated balanced accuracy of 0.724 but only 0.698 on left-out data; with the sampler inside an imblearn Pipeline the two were 0.732 and 0.727. Use imblearn.pipeline.Pipeline; the scikit-learn Pipeline does not accept samplers.

XGBoost scale_pos_weight

The XGBoost tuning notes say: if you care only about AUC, balance positive and negative weights with scale_pos_weight; if you care about predicting the right probability, you cannot re-balance the dataset, and setting max_delta_step to a finite number (say 1) helps convergence.

Reporting

TRIPOD+AI item 13 asks whether class imbalance methods were used, why and how, and how the model or its predictions were recalibrated afterwards. A model trained with SMOTE and not recalibrated usually has a calibration curve far from the diagonal.

Fatal errors

The figures below come from published methodological studies and official documentation examples and show how much these errors can inflate AUC or accuracy.

A common "intersect several machine learning algorithms to find hub genes" pipeline runs LASSO, SVM-RFE and random forest on one GEO dataset, takes the 3 to 5 genes in the intersection, and draws an ROC curve on the same dataset, often with AUC above 0.9. The intersection is not validation: all three algorithms saw every sample's outcome, so the intersection only reduces the number of genes and the selection bias remains. That ROC curve is the first error in the table. Ambroise and McLachlan's permuted-label experiment shows that under such a pipeline pure noise can reach near-zero error.

There are two acceptable approaches. First, put the whole pipeline (differential expression, selection by the three algorithms, intersection, model fitting) inside every iteration of an outer cross-validation or bootstrap, and report outer performance and each gene's selection frequency. Second, fix genes, coefficients and cut-offs in the development data and only predict and evaluate in another independent cohort (a different centre or period, preferably a different platform). Another series from the same platform and lab in GEO can supplement this, but it resembles the development data and says little about transportability.

In cross-platform validation (for example, development on TCGA RNA-seq and validation on GEO microarrays), the same coefficients are applied to expression values on different scales, so absolute risk scores are not comparable. A common workaround is z-scoring each gene within each cohort; discrimination (C-index, time-dependent AUC) can then be evaluated, while calibration requires re-estimating baseline risk in the new cohort, which should be stated in the paper.

Wrong practiceKnown consequenceCorrect practice
Selecting features (differential expression, LASSO, univariable tests) on all data, then splitting or cross-validatingscikit-learn example: 200 samples × 10,000 pure-noise features, SelectKBest(k=25) gives accuracy 0.76 versus 0.5 when done correctly. Ambroise and McLachlan 2002: with permuted labels on colon cancer data, cross-validated error under selection bias was near 0, while correct external cross-validation gave 0.40–0.45Put selection inside a Pipeline or each training fold; keep the independent validation set out of the entire development process
Scaling, imputing or batch-correcting on all dataTest-set means and variances leak into training; the size of the bias depends on the dataFit the scaler and imputation model on the training fold and apply them to the test fold
SMOTE or undersampling before the splitSynthetic samples carry test-set information; in the imblearn example cross-validated scores exceed left-out scoresKeep samplers inside training folds
Tuning and reporting with the same cross-validationErrors far below 50% even on data without signal (Varma and Simon 2006)Nested cross-validation, or tuning redone inside each bootstrap sample
Reporting training AUC onlyRandom forest training AUC approaches 1; logistic apparent AUC is also optimisticReport optimism-corrected or outer cross-validation results, then validate externally
Records from the same patient in both training and test setsThe model memorises individuals and test performance is inflatedSplit by patient (GroupKFold)
Using pROC's default direction = "auto" on validation datapROC help: when resampling or randomising, curves are biased towards higher AUCSet direction = "<" or ">" explicitly
Re-deriving cut-offs, refitting coefficients, or splitting high/low risk at the validation-set medianThis is refitting on the validation set, not validationFix coefficients, cut-offs and risk-group thresholds in the development set

Evaluation

TRIPOD+AI item 12e asks for the measures and plots used to evaluate discrimination, calibration and clinical utility, and item 23a asks for performance estimates with confidence intervals. Reporting AUC alone is among the problems reviewers point out most often.

R: AUC with CI, calibration intercept and slope, decision curver
library(pROC); library(rms); library(dcurves)
# p: predicted probabilities from external validation (or outer CV / bootstrap-corrected); y: 0/1 outcome
r <- roc(y, p, direction = "<", quiet = TRUE)    # set direction explicitly on validation data
ci.auc(r)                                        # DeLong 95% CI by default
# roc.test(r1, r2)                               # paired DeLong test for two non-nested models in the same patients

val.prob(p, y)                                   # C, Brier, calibration intercept, slope, Emax, and a smoothed calibration curve

df <- data.frame(y = y, model = p)
dca(y ~ model, data = df, thresholds = seq(0, 0.5, by = 0.01)) |>
  plot(smooth = TRUE)                            # Treat All and Treat None are drawn as references

Four levels of calibration

Van Calster et al. (BMC Medicine 2019) define mean calibration (intercept), weak calibration (intercept and slope), moderate calibration (smoothed calibration curve) and strong calibration (agreement for every covariate pattern, unattainable in practice). With small samples, intercept and slope are enough; with adequate samples, add the smoothed curve.

Do not judge calibration by the Hosmer-Lemeshow test

The same paper notes that the H-L test uses artificial risk groups, its P value says nothing about the direction or size of miscalibration, and it has low power; in large samples it flags trivial deviations. Report the calibration intercept, slope and curve instead.

DCA thresholds and reading the curve

Net benefit = sensitivity × prevalence − (1 − specificity) × (1 − prevalence) × pt/(1 − pt), where pt is the threshold probability. Vickers et al. (2019) describe two common misreadings: using the decision curve to pick the threshold (the threshold range should come first, from the harms and benefits of the intervention), and not comparing with both "treat all" and "treat none". If the model is best only over part of the range, limit the conclusion to that range.

High AUC does not imply high net benefit

Net benefit reflects both discrimination and calibration. When resampling or overfitting inflates risks, net benefit at common thresholds can fall below "treat all" even if AUC is unchanged. Compute DCA from externally validated or cross-validated predictions.

When external validation looks worse

Look at discrimination and calibration separately. A small drop in AUC with an off calibration intercept points to a different baseline risk, which an intercept update can fix; a calibration slope well below 1 points to overfitting at development, addressed by logistic recalibration of intercept and slope. TRIPOD+AI item 12f asks how the model was updated.

MeasureQuestion answeredRPythonInterpretation
AUC / C statistic with 95% CIDoes the model separate people with and without the outcome?pROC::roc, ci.auc (DeLong)sklearn.metrics.roc_auc_score + bootstrap CI0.5 is chance; a lower value at external validation than at development is common and must be reported
Comparing two AUCsIs the new model better?pROC::roc.test (paired DeLong)Bootstrap CI of the differenceFor nested models (new model = old model + new variables), Demler et al. (2012) showed the DeLong test has incorrect size
Calibration intercept (calibration-in-the-large)Does the mean predicted risk equal the observed rate?rms::val.probstatsmodels logistic regression with logit(p) as offsetTarget 0; negative means overestimation
Calibration slopeAre predictions too extreme?rms::val.probLogistic regression with logit(p) as the only covariateTarget 1; below 1 means overfitting and predictions that are too extreme
Smoothed calibration curveDo predictions agree with observations across risk levels?rms::val.prob, calibratesklearn CalibrationDisplay (binned)About 200 events and 200 non-events are needed for stability
Brier scoreOverall error of probability predictionsrms::val.probsklearn brier_score_lossDepends on prevalence; use it to compare models within one dataset
Decision curve (DCA)Over a reasonable threshold range, is deciding with the model better than treating all or none?dcurves::dcaNo official Python dcurves; compute from the formulaFix the threshold range in advance from the clinical context

Nomograms and survival models

A nomogram draws a fitted model as a chart that can be used by hand; it adds nothing to the model's validity. A nomogram needs the same evidence as the model: internal validation, calibration and external validation.

R: logistic nomogram and bootstrap internal validation (rms 8.1.1)r
library(rms)                                     # checked against 8.1.1
dd <- datadist(dat); options(datadist = "dd")    # set before fitting, using the same data frame
fit <- lrm(y ~ rcs(age, 4) + sex + scoma + meanbp + pafi + alb + bili + crea,
           data = dat, x = TRUE, y = TRUE)       # x, y = TRUE are needed by validate and calibrate
nom <- nomogram(fit, fun = plogis, funlabel = "Risk of in-hospital death",
                fun.at = c(0.05, 0.1, 0.2, 0.4, 0.6, 0.8))
plot(nom)

set.seed(2026)
validate(fit, B = 200)       # optimism-corrected Dxy, slope, etc.; C = Dxy / 2 + 0.5; B defaults to only 40
cal <- calibrate(fit, B = 200)
plot(cal)                    # Apparent and Bias-corrected lines; report Bias-corrected
R: LASSO-Cox, Cox nomogram, calibration, time-dependent ROC and C-index (survival::pbc)r
library(survival); library(glmnet); library(rms); library(timeROC)
d <- na.omit(subset(pbc, !is.na(trt),
     select = c(time, status, age, albumin, bili, copper, protime, ast, edema, stage)))
d$death <- as.integer(d$status == 2)             # 2 = death; 1 = transplant, treated as censored
d$status <- NULL

# LASSO-Cox: y can be a Surv object (supported since glmnet 4.1)
X <- model.matrix(~ . - time - death, d)[, -1]
set.seed(2026)
cvc <- cv.glmnet(X, Surv(d$time, d$death), family = "cox", type.measure = "C")
coef(cvc, s = "lambda.1se")

# Cox nomogram: surv = TRUE and time.inc equal to the u used later in calibrate
dd <- datadist(d); options(datadist = "dd")
fit <- cph(Surv(time, death) ~ log(bili) + albumin + age + protime + edema,
           data = d, x = TRUE, y = TRUE, surv = TRUE, time.inc = 1826)
cox.zph(fit)                                     # proportional hazards assumption
sv <- Survival(fit)
plot(nomogram(fit, fun = list(function(lp) sv(1826, lp)), funlabel = "5-year survival"))
set.seed(2026)
cal <- calibrate(fit, u = 1826, cmethod = "KM", m = 50, B = 200)   # the default cmethod = "hare" needs the polspline package
plot(cal)

# Time-dependent ROC and C-index
lp <- predict(fit)                               # linear predictor; higher means higher risk
tr <- timeROC(T = d$time, delta = d$death, marker = lp, cause = 1,
              times = c(730, 1826), iid = TRUE)
tr$AUC; confint(tr)
concordance(Surv(time, death) ~ lp, data = d, reverse = TRUE)                   # Harrell's C
concordance(Surv(time, death) ~ lp, data = d, reverse = TRUE, timewt = "n/G2")  # Uno's C

u and time.inc in calibrate.cph

u is the time point at which calibration is assessed; use the same value and time unit as time.inc in cph (days in pbc; 1,826 days is about 5 years). With cmethod = "KM", m is the number of patients per group, grouped by predicted survival; the default cmethod = "hare" requires the polspline package.

C-index versus time-dependent AUC

Harrell's C depends on the censoring distribution, so with long follow-up and heavy censoring it is hard to compare across studies; Uno's C (timewt = "n/G2" in survival::concordance) depends less on censoring. Time-dependent AUC (timeROC) answers whether the model separates people with and without an event by year t; report clinically meaningful time points such as 1, 3 and 5 years with 95% CI (iid = TRUE).

Common LASSO-Cox practice and its problems

A typical TCGA prognostic pipeline runs univariable Cox screening, LASSO-Cox, then splits patients into high and low risk at the training-set median and draws Kaplan-Meier curves. Median splits discard information, and a log-rank P value between groups does not measure predictive accuracy; report the continuous risk score's C-index, time-dependent AUC and calibration. A "survival model" fitted with family = "binomial" on death yes/no discards follow-up time.

Proportional hazards assumption

If the global cox.zph test gives P < 0.05, inspect the Schoenfeld residual plots to find the offending variables, and handle them with stratification or time interactions. In large samples the test flags small departures, so rely mainly on the residual plots.

Forest plots

A forest plot shows the HR or OR with 95% CI for each variable in a multivariable Cox or logistic model. survminer::ggforest takes a coxph object and needs data passed as well; the forestplot package allows custom table columns. A forest plot shows associations and says nothing about each variable's contribution to predictive performance.

Error messageCauseFix
variable xxx does not have limits defined in fit or with datadistdatadist was not set before fitting, was built from a different data frame, or the formula contains dat$xRun dd <- datadist(dat); options(datadist = "dd") first, and use column names with the data = argument in the formula
did not use surv=TRUE for cph( )calibrate or Survival needs the model to store survival estimatescph(..., surv = TRUE, x = TRUE, y = TRUE, time.inc = u)
fit did not use x=TRUE,y=TRUEvalidate and calibrate need the design matrix and outcomeAdd x = TRUE, y = TRUE to lrm or cph
when lp.at is not specified you must specify x=TRUE in the fitnomogram needs the design matrix to set the range of the linear predictorAdd x = TRUE when fitting
Error text taken from stop() calls in the rms 8.1.1 source.

Interpretability

SHAP decomposes a single prediction into feature contributions and is useful for showing what a tree model has learned. The SHAP documentation has an article titled "Be careful when interpreting predictive models in search of causal insights", explaining that SHAP shows the correlations a model exploits.

Python: SHAP plots for an XGBoost model (shap 0.52)python
import shap                                      # 0.52
explainer = shap.TreeExplainer(xgb_model)        # xgb_model: a fitted XGBClassifier
sv = explainer(X_test)                           # compute on the test set or outer folds, not the training set
shap.plots.beeswarm(sv)                          # for binary outcomes the x-axis is in log-odds, not probability
shap.plots.scatter(sv[:, "age"], color=sv)       # dependence plot: non-linearity and interactions

Units and the data used

For binary XGBoost models, TreeExplainer returns contributions on the log-odds scale by default; the values cannot be read directly as probability changes. Compute on the test set or outer folds; on the training set, noise learned through overfitting also appears as an "important feature".

Correlated features share credit

Two highly correlated variables (such as creatinine and urea nitrogen) split their SHAP values, so ranking alone understates both, and their order can swap with another random seed. Discuss correlated variables as a group.

Conclusions SHAP cannot support

A high SHAP value does not mean that changing the variable changes the outcome, so it cannot be written up as a "risk factor" or "therapeutic target". When SHAP rankings disagree across algorithms, their intersection is not a "robust biomarker" either. SHAP explains the model; it cannot fix a model that fails validation.

Reporting

State the SHAP version, explainer type, background data and the dataset on which values were computed. Place SHAP plots after the validation results, as a description of a validated model.

Reporting and peer review

TRIPOD+AI (Collins et al., BMJ 2024;385:e078378) has 27 items, covers regression and machine learning models, and supersedes TRIPOD 2015. PROBAST+AI (Moons et al., BMJ 2025;388:e082505) assesses risk of bias, with 16 signalling questions for model development and 18 for model evaluation, and supersedes PROBAST 2019. Check your manuscript against both before submission.

"What justifies the sample size? EPV is only 5"

Give the Riley calculation; if the sample is short, explain that candidate parameters were reduced and penalised regression was used, and report the bootstrap-corrected calibration slope. Do not rely on EPV ≥ 10 alone.

"There is no external validation"

Do temporal or geographic validation if independent data exist; otherwise use bootstrap optimism correction or nested cross-validation as internal validation, avoid claiming generalisability in the title and abstract, and state the limitation. Calling a random 30% of the same dataset "external validation" will be flagged.

"Why machine learning, and was it compared with logistic regression?"

Show discrimination, calibration and DCA of a spline logistic or Cox baseline in the same nested cross-validation; if the difference is small, say so and give the reason for the choice (for example interpretability or deployment).

"How is calibration?"

Give the calibration intercept, slope and a bootstrap-corrected smoothed calibration curve, not only an H-L P value. For models trained with SMOTE, explain how they were recalibrated.

"Are the feature genes just an artefact of overfitting?"

Report selection frequencies across resamples, the outer-loop performance of the whole selection pipeline, and the performance of the fixed model in an independent cohort. Functional experiments answer a biological question and cannot replace validation of predictive performance.

TRIPOD+AI itemRequirementWriting tip
7 Data preparationPre-processing and quality checks, and whether they were similar across groupsState at which step and on which data scaling, batch correction and outlier handling were fitted
10 Sample sizeHow the study size was arrived at, separately for development and evaluationGive the pmsampsize inputs and output
11 Missing dataHow missing data were handled and whyReport missingness per variable and the imputation model
12a Data useHow the data were used and partitionedState the internal validation method, number of folds, repeats and bootstrap samples
12b Predictor handlingFunctional form, transformation, standardisationWhether splines were used and whether any variable was dichotomised
12c Model and tuningModel type, rationale, all model-building steps including hyperparameter tuning, and internal validation methodList the tuning grid, inner cross-validation settings and final hyperparameters
12e Performance measuresMeasures and plots for discrimination, calibration and clinical utilityAUC or C-index, calibration intercept and slope, calibration curve, DCA
12f Model updatingFor example recalibrationState any intercept or slope update at external validation
13 Class imbalanceWhether used, why, how, and any subsequent recalibrationIf none, write "no resampling was performed"
14 FairnessApproaches to model fairness and rationaleReport performance in key subgroups such as sex and age
18c–18f Open scienceProtocol, registration, data and code availabilityPost code in a public repository with software versions and random seeds
23a Model performancePerformance estimates with confidence intervals, including key subgroupsGive a 95% CI for every measure
Item numbers and content follow the TRIPOD+AI paper (PMC11019967).

Chinese-language practice

The points below come from Zhihu columns, "Xiaobai Xue Tongji" articles republished by Mediecogroup, and a bioinformatics tutorial on cnblogs, checked against English methodological literature and software documentation. Templated CSDN tutorials and an article with fabricated reviewer comments were excluded.

The V in EPV means parameters

Jin Shuai (Zhihu, 2022) and Xiaobai Xue Tongji (Mediecogroup, 2024) both stress that a multi-category variable corresponds to several dummy parameters and that non-linear and interaction terms also count; some simulations suggest 20 to 50 EPP. This matches the definition of parameters in the pmsampsize documentation.

Reference numbers for external validation

Jin Shuai cites Pavlou et al. (2021): with C between 0.64 and 0.85 and event rates between 0.05 and 0.30, keeping the standard error of C at validation below 0.025 needs 60 to 170 events; Snell et al. (2021) recommend at least 200 events. This points the same way as Riley 2024: the 100-event rule is too low.

A fixed 0.05 margin is not always appropriate

When introducing the Riley method, Xiaobai Xue Tongji notes that an intercept margin of 0.05 and an R² difference of 0.05 are generic settings, that applying them uniformly is not reasonable, and that clinical knowledge and prior literature should guide them. For example, when the outcome rate is only 5%, an overall risk margin of ±0.05 is as large as the rate itself.

To correct: changing seeds, default folds and survival outcomes

A cnblogs TCGA LASSO tutorial says "if the result is not ideal, change the seed" and states that cv.glmnet uses 20 folds by default. The default nfolds in cv.glmnet is 10, and picking seeds is data snooping (see the LASSO section). The tutorial is titled as a survival model but uses family = "binomial"; it should use a Surv object with family = "cox".

glmnet defaults to lambda.1se

The Zhihu column "Thoughts on LASSO regression and feature selection" (2020) notes that glmnet uses 1se by default when predicting, which matches the default of s in the predict.cv.glmnet help. Readers who copied coef(cvfit) should check which lambda they actually reported.

Hand it to an agent

Sample size calculation, repeated cross-validation and bootstrap, multi-algorithm comparison and plotting are time-consuming but rule-based steps that a scientific agent can run; the research question, prediction time point and interpretation remain the researcher's decisions.

Example instruction: "Here is my cohort data cohort.csv. The outcome is 30-day death and the prediction time point is 24 hours after admission. First calculate the sample size with pmsampsize, then use spline logistic regression as the baseline and compare LASSO, random forest and XGBoost. Report AUC, calibration intercept and slope, and DCA with 5×5 nested cross-validation, draw a nomogram, and list what the methods section needs under TRIPOD+AI."

The agent works in an isolated cloud computer: it checks variable types, missingness and any variables only available after the prediction time point; runs pmsampsize; places imputation, scaling, selection and tuning in one Pipeline or bootstrap loop; checks LASSO stability across 100 seeds; produces nested cross-validation results, calibration curves, decision curves, a nomogram and SHAP plots for each model; and generates a TRIPOD+AI checklist. scikit-learn, statsmodels, pandas and others are preinstalled, and R packages are installed by the agent in the workspace. The agent reviews results adversarially and checks for leaking steps; the workspace keeps code, random seeds, software versions, logs and results for reproduction.

You still need to check: whether the outcome definition and prediction time point fit the clinical setting; whether every candidate variable is available at the prediction time point; whether the DCA threshold range is reasonable; whether the external validation data are truly independent; and the clinical interpretation of the results.

References

FAQ

LASSO selects different variables every run. Which run should I use?

None on its own. Fix foldid for reproducibility, then show stability with an error curve averaged over many fold assignments or with bootstrap selection frequencies. In our run on one dataset, 100 seeds gave 7 variable sets at lambda.1se, and averaging 100 error curves gave a single result. Picking the seed with the best-looking result is data snooping.

Training AUC is 0.95 but validation AUC is only 0.70. Is that normal?

It indicates overfitting, or a leaking step that inflated training performance. Check whether selection, scaling or resampling was done before the split, and whether a random forest AUC was computed with predict(rf, newdata = training data). Report optimism-corrected or nested cross-validation results as model performance, not training AUC.

What if I have no external validation data?

Use bootstrap optimism correction (200 or more samples, redoing every modelling step) or nested cross-validation for internal validation, report the corrected AUC, calibration slope and calibration curve, and state the lack of external validation as a limitation. A validation set split at random from the same data is internal validation.

The Hosmer-Lemeshow test gives P < 0.05. Is the model unusable?

That test cannot decide it. In large samples the H-L test flags trivial deviations, in small samples it has low power, and its P value does not show the direction of miscalibration. Look at the calibration intercept, slope and smoothed curve: an intercept near 0, a slope near 1 and a curve close to the diagonal over the clinically relevant risk range are what matter.

After LASSO selection, should I run a multivariable regression?

For a prediction model, use the LASSO coefficients directly. An unpenalised refit removes the shrinkage and inflates the coefficients, and dropping variables by P value makes overfitting worse. If unshrunken coefficients are needed, use relax = TRUE and put the whole two-step pipeline inside bootstrap validation.

Does another GEO dataset count as external validation?

It counts when genes, coefficients and cut-offs are fixed and you only predict and evaluate in another independent cohort; a different centre, period or platform says more about transportability. Rerunning LASSO, refitting coefficients or splitting at the validation-set median on that dataset is refitting the model.

Let an agent run the nested cross-validation and bootstrap

Give your data and research question to Scientify's scientific agent: it calculates sample size, runs nested cross-validation across algorithms, produces calibration, DCA and nomograms in an isolated cloud computer, keeps running after you close your machine, and keeps all code and logs in the workspace. New users receive $5 of free credit.