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.
| Step | Recommended practice | Redone in each resample? | Common error |
|---|---|---|---|
| Define the prediction question | State 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 size | Calculate 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 data | Multiple imputation or in-model handling; fit the imputation model on training data only | Yes | Dropping incomplete cases without reporting the proportion |
| Continuous variables | Keep them continuous; model non-linearity with restricted cubic splines (rcs) | — | Dichotomising at the median or an "optimal" ROC cut-off |
| Variable selection | LASSO, or pre-specification from literature and clinical knowledge | Yes | Selecting on all data and then splitting into training and validation sets |
| Scaling | Fit scaling parameters on the training fold and apply them to the test fold | Yes | Scaling all data at once |
| Class imbalance | Usually leave it; if corrected, apply only to training folds and recalibrate | Yes | Running SMOTE before the split |
| Tuning | Choose hyperparameters with inner cross-validation | Yes (nested) | Using one cross-validation both to tune and to report performance |
| Internal validation | Bootstrap optimism correction or nested cross-validation | — | Reporting training AUC only; a random 70:30 split in a modest sample |
| Evaluation | Discrimination + calibration + DCA, each with 95% CI | — | AUC only; the Hosmer-Lemeshow test in place of a calibration curve |
| External validation | Temporally or geographically independent data with coefficients and cut-offs held fixed | — | Refitting coefficients or re-deriving cut-offs in the validation set |
| Reporting | TRIPOD+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.
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.18The 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.
| Inputs | Required sample size | Events | EPP (events per parameter) | Binding criterion |
|---|---|---|---|---|
| Prevalence 0.253, C = 0.80, 30 parameters | 1136 | 288 | 9.58 | Shrinkage ≥ 0.9 |
| Prevalence 0.253, C = 0.70, 30 parameters | 2732 | 692 | 23.04 | Shrinkage ≥ 0.9 |
| Prevalence 0.10, C = 0.80, 10 parameters | 762 | 77 | 7.62 | Shrinkage ≥ 0.9 |
| Survival: Cox-Snell R² = 0.10, 15 parameters, event rate 0.08/year, 3 years, mean follow-up 4 years | 1274 | 408 | 27.18 | Shrinkage ≥ 0.9 and precision of 3-year risk |
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.
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 curveScaling: 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.
| Approach | Variables selected | Distinct variable sets | Notes |
|---|---|---|---|
| lambda.min, 100 random seeds | 19–21 | 3 | lambda.min ranged 0.00566–0.00989 |
| lambda.1se, 100 random seeds | 10–18 (median 12) | 7 | The most frequent set appeared 37 times; lambda.1se ranged 0.0157–0.0399 |
| Error curve averaged over 100 fold assignments | lambda.min 21; lambda.1se 12 | 1 | The result no longer depends on a single seed |
| 100 bootstrap resamples, lambda.1se | — | 98 | scoma, pafi and alb selected 100 times; hrt and bili 99; crea 98; age 56; urine 51 |
| Same folds, standardize = FALSE | 8 (12 when TRUE) | — | Variables with wide ranges (such as urine output) are kept more easily; the composition changes |
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.
| Method | When it fits | Key hyperparameters (starting points) | Probability output | Common misuse |
|---|---|---|---|---|
| Logistic / Cox regression (with splines) | Tabular clinical data, modest number of variables, a nomogram and interpretable coefficients are needed | 3–5 spline knots | Usually well calibrated | Dichotomising continuous variables; stepwise selection |
| LASSO / elastic net | Many candidates or high-dimensional expression data; a sparse model is needed | lambda (inner CV), alpha | Shrunken; calibration slope close to 1 | Two-step refit that removes shrinkage; seed picking |
| Random forest | Complex interactions and non-linearity, large samples | mtry (R default √p), ntree ≥ 500, minimum node size | With minimum node size 1, probabilities are pushed to the extremes and training AUC approaches 1 | Reporting training performance with predict(rf, newdata = training data) |
| XGBoost | Large samples, many variables, interactions | learning_rate 0.01–0.1, max_depth 2–4, min_child_weight, subsample, early stopping | Usable with the default logloss objective; distorted after scale_pos_weight | Fitting a small sample with the defaults learning_rate 0.3 and max_depth 6 |
| SVM | Moderate samples, continuous features, ranking only | C, gamma (RBF kernel); scaling is required | scikit-learn probability=True uses internal 5-fold Platt scaling; predict and predict_proba can disagree | Treating decision_function as a probability on a calibration plot |
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.
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))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))| Method | Procedure | When to use | Problems |
|---|---|---|---|
| Random split (e.g. 70:30) | One split into training and validation sets | Very 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-validation | Every observation is used for validation once | Evaluating one fixed pipeline | A single 10-fold run depends on the fold assignment |
| Repeated k-fold cross-validation | Repeat 10-fold several times and average | When a stable estimate is needed | Harrell notes that about 100 repeats of 10-fold are needed to match the bootstrap |
| Bootstrap optimism correction | Redo every modelling step in each bootstrap sample, evaluate on the original data, average the optimism | Regression models, rms workflows; all data used for development | Selection and tuning must be redone every time, which is computationally heavy |
| Nested cross-validation | Inner loop tunes, outer loop estimates performance | Machine learning models with hyperparameters; comparing algorithms | The 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.
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 practice | Known consequence | Correct practice |
|---|---|---|
| Selecting features (differential expression, LASSO, univariable tests) on all data, then splitting or cross-validating | scikit-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.45 | Put 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 data | Test-set means and variances leak into training; the size of the bias depends on the data | Fit the scaler and imputation model on the training fold and apply them to the test fold |
| SMOTE or undersampling before the split | Synthetic samples carry test-set information; in the imblearn example cross-validated scores exceed left-out scores | Keep samplers inside training folds |
| Tuning and reporting with the same cross-validation | Errors 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 only | Random forest training AUC approaches 1; logistic apparent AUC is also optimistic | Report optimism-corrected or outer cross-validation results, then validate externally |
| Records from the same patient in both training and test sets | The model memorises individuals and test performance is inflated | Split by patient (GroupKFold) |
| Using pROC's default direction = "auto" on validation data | pROC help: when resampling or randomising, curves are biased towards higher AUC | Set direction = "<" or ">" explicitly |
| Re-deriving cut-offs, refitting coefficients, or splitting high/low risk at the validation-set median | This is refitting on the validation set, not validation | Fix 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.
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 referencesFour 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.
| Measure | Question answered | R | Python | Interpretation |
|---|---|---|---|---|
| AUC / C statistic with 95% CI | Does the model separate people with and without the outcome? | pROC::roc, ci.auc (DeLong) | sklearn.metrics.roc_auc_score + bootstrap CI | 0.5 is chance; a lower value at external validation than at development is common and must be reported |
| Comparing two AUCs | Is the new model better? | pROC::roc.test (paired DeLong) | Bootstrap CI of the difference | For 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.prob | statsmodels logistic regression with logit(p) as offset | Target 0; negative means overestimation |
| Calibration slope | Are predictions too extreme? | rms::val.prob | Logistic regression with logit(p) as the only covariate | Target 1; below 1 means overfitting and predictions that are too extreme |
| Smoothed calibration curve | Do predictions agree with observations across risk levels? | rms::val.prob, calibrate | sklearn CalibrationDisplay (binned) | About 200 events and 200 non-events are needed for stability |
| Brier score | Overall error of probability predictions | rms::val.prob | sklearn brier_score_loss | Depends 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::dca | No official Python dcurves; compute from the formula | Fix 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.
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-correctedlibrary(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 Cu 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 message | Cause | Fix |
|---|---|---|
| variable xxx does not have limits defined in fit or with datadist | datadist was not set before fitting, was built from a different data frame, or the formula contains dat$x | Run 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 estimates | cph(..., surv = TRUE, x = TRUE, y = TRUE, time.inc = u) |
| fit did not use x=TRUE,y=TRUE | validate and calibrate need the design matrix and outcome | Add x = TRUE, y = TRUE to lrm or cph |
| when lp.at is not specified you must specify x=TRUE in the fit | nomogram needs the design matrix to set the range of the linear predictor | Add x = TRUE when fitting |
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.
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 interactionsUnits 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 item | Requirement | Writing tip |
|---|---|---|
| 7 Data preparation | Pre-processing and quality checks, and whether they were similar across groups | State at which step and on which data scaling, batch correction and outlier handling were fitted |
| 10 Sample size | How the study size was arrived at, separately for development and evaluation | Give the pmsampsize inputs and output |
| 11 Missing data | How missing data were handled and why | Report missingness per variable and the imputation model |
| 12a Data use | How the data were used and partitioned | State the internal validation method, number of folds, repeats and bootstrap samples |
| 12b Predictor handling | Functional form, transformation, standardisation | Whether splines were used and whether any variable was dichotomised |
| 12c Model and tuning | Model type, rationale, all model-building steps including hyperparameter tuning, and internal validation method | List the tuning grid, inner cross-validation settings and final hyperparameters |
| 12e Performance measures | Measures and plots for discrimination, calibration and clinical utility | AUC or C-index, calibration intercept and slope, calibration curve, DCA |
| 12f Model updating | For example recalibration | State any intercept or slope update at external validation |
| 13 Class imbalance | Whether used, why, how, and any subsequent recalibration | If none, write "no resampling was performed" |
| 14 Fairness | Approaches to model fairness and rationale | Report performance in key subgroups such as sex and age |
| 18c–18f Open science | Protocol, registration, data and code availability | Post code in a public repository with software versions and random seeds |
| 23a Model performance | Performance estimates with confidence intervals, including key subgroups | Give a 95% CI for every measure |
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
- Collins GS, et al. TRIPOD+AI statement. BMJ 2024;385:e078378 — 27-item reporting checklist; items 7, 10–14, 18 and 23a
- Moons KGM, et al. PROBAST+AI. BMJ 2025;388:e082505 — Risk of bias and applicability; 16 development and 18 evaluation signalling questions
- Wolff RF, et al. PROBAST. Ann Intern Med 2019;170:51-58 — Original risk-of-bias tool; bias from univariable selection and dichotomisation
- Riley RD, et al. Calculating the sample size required for developing a clinical prediction model. BMJ 2020;368:m441 — Criteria and steps for development sample size
- pmsampsize 1.1.3 reference manual (CRAN) — Argument definitions and examples; cstatistic should be optimism-adjusted
- Riley RD, et al. Evaluation of clinical prediction models (part 3): sample size for external validation. BMJ 2024;384:e074821 — Four criteria for external validation sample size and the 949-patient example
- van Smeden M, et al. Sample size for binary logistic prediction models: beyond events per variable criteria. Stat Methods Med Res 2019 — EPV is weakly related to prediction error
- Peduzzi P, et al. A simulation study of the number of events per variable in logistic regression analysis. J Clin Epidemiol 1996 — Origin of EPV ≥ 10
- van der Ploeg T, Austin PC, Steyerberg EW. Modern modelling techniques are data hungry. BMC Med Res Methodol 2014 — EPV needed by random forest, SVM and neural networks
- Riley RD, et al. Penalization and shrinkage methods produced unreliable clinical prediction models especially when sample size was small. J Clin Epidemiol 2021 — Uncertainty of penalty parameters in small samples
- Christodoulou E, et al. A systematic review shows no performance benefit of machine learning over logistic regression. J Clin Epidemiol 2019 — 282 comparisons; no difference at low risk of bias
- Heinze G, Wallisch C, Dunkler D. Variable selection – a review and recommendations for the practicing statistician. Biom J 2018 — Post-selection inference and the two-step problem
- Sun GW, Shook TL, Kay GL. Inappropriate use of bivariable analysis to screen risk factors. J Clin Epidemiol 1996 — Problems with univariable screening
- glmnet documentation: An Introduction to glmnet — Default standardize; definitions of lambda.min and lambda.1se
- van den Goorbergh R, et al. The harm of class imbalance corrections for risk prediction models. JAMIA 2022 — Undersampling, oversampling and SMOTE harm calibration without improving AUC
- imbalanced-learn: Common pitfalls and recommended practices — Resampling leakage example and Pipeline use
- scikit-learn: Common pitfalls and recommended practices — Feature-selection leakage example (0.76 vs 0.5) and random_state advice
- Ambroise C, McLachlan GJ. Selection bias in gene extraction on the basis of microarray gene-expression data. PNAS 2002 — Near-zero cross-validated error under selection bias with permuted labels
- Varma S, Simon R. Bias in error estimation when using cross-validation for model selection. BMC Bioinformatics 2006 — Bias of tuned cross-validation error and nested cross-validation
- Harrell F. Split-Sample Model Validation (blog) — Split-sample instability below about 20,000; about 100 repeats of 10-fold match the bootstrap
- Van Calster B, et al. Calibration: the Achilles heel of predictive analytics. BMC Med 2019 — Calibration hierarchy, intercept and slope, against the H-L test, 200 events
- Vickers AJ, van Calster B, Steyerberg EW. A simple, step-by-step guide to interpreting decision curve analysis. Diagn Progn Res 2019 — Net benefit formula and common misreadings
- Demler OV, Pencina MJ, D'Agostino RB. Misuse of DeLong test to compare AUCs for nested models. Stat Med 2012 — Comparing AUCs of nested models
- XGBoost documentation: Notes on Parameter Tuning — AUC versus probability under class imbalance
- scikit-learn documentation: Support Vector Machines — Platt scaling with probability=True and inconsistencies
- SHAP documentation: Be careful when interpreting predictive models in search of causal insights — SHAP and causal interpretation
- R-help: help with nomogram function (reply by Harrell) — datadist errors and $ in formulas
- Practitioner post: Jin Shuai, minimum sample size for developing and validating clinical prediction models (Zhihu, Chinese) — Parameter counting and external validation sample size
- Practitioner post: Xiaobai Xue Tongji, how to estimate sample size for clinical prediction models (Mediecogroup, Chinese) — Chinese explanation of EPP and the Riley criteria
- Practitioner post: TCGA analysis workflow 3.3, survival model with LASSO (cnblogs, Chinese) — Seed and fold claims that need correction
- Practitioner post: Thoughts on LASSO regression and feature selection (Zhihu, Chinese) — glmnet defaults to lambda.1se