suppressPackageStartupMessages({
library(tidyverse)
library(yardstick)
library(cowplot)
library(ggrepel)
library(ggbeeswarm)
})
theme_set(theme_cowplot())
options(repr.plot.width = 15, repr.plot.height = 9)
set.seed(42)A performance measure is called proper if its expected value is optimal when using the correct model, which is the model that gives the correct probabilities based on the predictors or features in the model. Here, expected value refers to the average value obtained after repeating the validation study multiple times. In any given dataset, particularly when sample size is low, the correct model can be outperformed by an incorrect model due to random variation. The importance of properness is that a proper measure cannot be fooled: in expectation, the correct model cannot be outperformed by an incorrect one. A measure is strictly proper when its expected value is optimal only for the correct model. When the expected value is optimal for the correct model and for some incorrect models, a measure is called semi-proper. When an incorrect model can have a better expected value than the correct model, the measure is termed improper and cannot be trusted.
The above is a quote from the paper “Evaluation of performance measures in predictive artificial intelligence models to support medical decisions: overview and guidance”. In this notebook, I reproduced the results from the Supplementary Appendix 1 of this paper, which demonstrates the properness of some performance metrics.
Generate data¶
First we generate some synthetic data, the paper does not provide code for this section but describes:
To illustrate the property of properness, we consider a hypothetical situation with four continuous predictors that have a standard normal distribution and that are correlated with each other (Pearson correlation 0.4 between any pair of predictors). The correct model for outcome Y is a logistic model with intercept -1 and predictor coefficients of 0.74, 0.18, 0.18, and 0.18. This setup corresponds to a true AUROC of 0.746 and a prevalence of 0.304. The clinically relevant decision threshold is set to 0.1.
n_samples = 20000
intercept = -1.0
coefficients = c(0.74, 0.18, 0.18, 0.18)
n_features = length(coefficients)corr_mat = matrix(0.4, n_features, n_features)
diag(corr_mat) = 1
corr_matL = chol(corr_mat) # Cholesky decomposition
Z = matrix(rnorm(n_samples * n_features), n_samples, n_features)
X = Z %*% L
cor(X)cor(X) %>%
.[row(.)!=col(.)] |> # exclude diagonal
mean() # paper's target correlation is 0.4Generate models¶
12 different models are proposed, model 1 is the correct model, and the other eleven are variations that should perform worse.
# 1. true model
trueLP = as.vector(intercept + X %*% coefficients)
probs_1 = plogis(trueLP)
y_true = as.numeric(runif(n_samples) < probs_1)mean(y_true) # paper's target prevalence is 0.304# 2. expit(trueLP + 0.75)
probs_2 = plogis(trueLP + 0.75)# 3. expit(trueLP - 1)
probs_3 = plogis(trueLP - 1)# 4. expit(trueLP/1.3)
probs_4 = plogis(trueLP / 1.3)# 5. expit(trueLP*2)
probs_5 = plogis(trueLP * 2)# 6. expit(trueLP*2 - 1)
probs_6 = plogis(trueLP * 2 - 1)# 7. prob<0.1 are shrunk by a factor of 10 and prob>=0.1 are blown up with 1 - (0.1 * (1 - prob))
probs_7 = ifelse(probs_1 < 0.1, 0.1 * probs_1, 1 - (0.1 * (1 - probs_1)))# 8. like 7 but but use prevalence as threshold
probs_8 = ifelse(probs_1 < mean(y_true), 0.1 * probs_1, 1 - (0.1 * (1 - probs_1)))# 9. like 7 but use 0.5 as threshold
probs_9 = ifelse(probs_1 < 0.5, 0.1 * probs_1, 1 - (0.1 * (1 - probs_1)))# 10. 0.04 is added or subtracted at random to prob range [0.051, 0.949]
probs_10 = ifelse(probs_1 >= 0.051 & probs_1 <= 0.949, probs_1+ifelse(rbinom(n_samples, 1, 0.5), 0.04, -0.04), probs_1)# 11. expit(trueLP + U(-0.2,0.2)), a random value in the range [-0.2, 0.2]
probs_11 = plogis(trueLP + runif(n_samples, min=-0.2, max=0.2))# 12. use coefficients 0.74, 0.74, 0.18, and 0.18
modLP = as.vector(intercept + X %*% c(0.74, 0.74, 0.18, 0.18))
probs_12 = plogis(modLP)df <- tibble(
y_true,
probs_1, probs_2, probs_3, probs_4,
probs_5, probs_6, probs_7, probs_8,
probs_9, probs_10, probs_11, probs_12
) |>
pivot_longer(names_to = 'model', values_to = 'prob', contains('probs')) |>
mutate(
gt=factor(ifelse(y_true, '1','0'), levels=c('1','0')),
model=gsub('probs_', '',model),
model=factor(model, levels=sort(as.numeric(unique(model)))),
)filter(df, model==1) |>
roc_auc(gt, prob) # paper's target roc_auc is 0.746df |>
ggplot(aes(x=gt, y=prob)) +
geom_violin() +
facet_wrap(~model)
Evaluation¶
To calculate the performance metrics, I will use the yardstick package. See their doc for the definition of each metric used here.
Class Probability Metrics¶
Class probability metrics are measures that evaluate model performance based on the predicted probability assigned to the target classes.
These metrics do not depend on a definition of a prediction threshold.
ms = metric_set(roc_auc, pr_auc, mn_log_loss, average_precision, brier_class, gain_capture)
df |>
group_by(model) |>
ms(gt, prob) |>
mutate(
best_metric = ifelse(.metric %in% c('brier_class','mn_log_loss'), .estimate == min(.estimate), .estimate == max(.estimate)),
.by = .metric
) |>
ggplot(aes(y=model, x=.estimate, color=best_metric)) +
geom_point() +
geom_label_repel(aes(label=model), max.overlaps = Inf, min.segment.length = 0, box.padding = 0.3) +
facet_wrap(~.metric, scales='free')
mn_log_loss and brier_class are strictly proper metrics, therefore, only the correct model has the best metric.
The other metrics are semi-proper, because we can’t distinguish between the correct model and the others.
Classification Metrics¶
Classification metrics are measures that evaluate model performance based on the predicted class labels.
These metrics require a prediction threshold to transform a predicted probabilities into discrete class predictions. For this experiment, the threshold was set at 0.1.
ms = metric_set(mcc, kap, j_index, f_meas, accuracy, bal_accuracy)
df |>
mutate(
est=factor(ifelse(prob>0.1, '1','0'), levels=c('1','0'))
) |>
group_by(model) |>
ms(gt, estimate=est) |>
mutate(
best_metric = .estimate == max(.estimate),
.by = .metric
) |>
ggplot(aes(y=model, x=.estimate, color=best_metric)) +
geom_point() +
geom_label_repel(aes(label=model), max.overlaps = Inf, min.segment.length = 0, box.padding = 0.3) +
facet_wrap(~.metric, scales='free')
All metrics tested were improper, because the correct model had a worse metric than some of the other models.
Regression metrics¶
Regression probability metrics are measures that evaluate model performance based on the predicted continuous output values rather than discrete categories.
ms = metric_set(rmse, rsq, rsq_trad, mae, ccc, huber_loss, iic, poisson_log_loss, smape, msd, )
df |>
group_by(model) |>
ms(y_true, prob) |>
mutate(
best_metric = case_when(
.metric == 'msd' ~ abs(.estimate) == min(abs(.estimate)),
.metric %in% c('ccc','iic','rsq','rsq_trad') ~ .estimate == max(.estimate),
TRUE ~ .estimate == min(.estimate)
),
.by = .metric
) |>
ggplot(aes(y=model, x=.estimate, color=best_metric)) +
geom_point() +
geom_label_repel(aes(label=model),max.overlaps = Inf, min.segment.length = 0, box.padding = 0.3) +
facet_wrap(~.metric, scales='free')
rmse, huber_loss, rsq, rsq_trad and poisson_log_loss are strictly proper.
The others are improper.
- Van Calster, B., Collins, G. S., Vickers, A. J., Wynants, L., Kerr, K. F., Barreñada, L., Varoquaux, G., Singh, K., Moons, K. G., Hernandez-Boussard, T., Timmerman, D., McLernon, D. J., van Smeden, M., & Steyerberg, E. W. (2025). Evaluation of performance measures in predictive artificial intelligence models to support medical decisions: overview and guidance. The Lancet Digital Health, 7(12), 100916. 10.1016/j.landig.2025.100916