| SOCR ≫ | DSPA ≫ | DSPA3 Topics ≫ |
library(ggplot2)
library(dplyr)
library(tidyr)
library(patchwork)
library(plotly) # interactive figures and ALL 3-D graphics
library(rsample) # splitting and resampling
library(yardstick) # metrics
library(caret) # confusionMatrix, train
library(pROC)How this chapter uses graphics
Every two-dimensional figure is drawn with
ggplot2and rendered statically. Immediately after each one, the equivalentplot_ly()code appears in a chunk markedeval=FALSE, echo=TRUE.Every three-dimensional figure is drawn with
plot_ly()and evaluated. Nearly every quantity in this chapter is a function of two variables, optimism of \((n, p)\), the cross-validation estimator’s variance of \((k, n)\), net benefit of (threshold, prevalence), the learning curve of (sample size, complexity). Each is a surface, and reading one from a fixed viewpoint discards the interaction that makes it interesting.
After completing this chapter you will be able to:
Estimated time: 11–14 hours including exercises. Prerequisites: Chapter 5 (the metric family and the leakage taxonomy of §5.4.1), Chapter 6 (regression metrics and temporal validation, §6.9), and Chapter 8 (the adjusted Rand index, §8.4.2). This chapter develops all three properly.
The DSPA3 Labs include a number of interactive webapps, which are build as portable, self-contained, and browser accessible applications. These apps demonstrate various concepts discussed in this chapter, including
Every number in this chapter estimates something. Before computing any of them, it is worth writing down precisely what.
Let \((X,Y)\sim P\) and let \(\hat f\) be a model fitted to a training sample \(\mathcal{T}=\{(x_i,y_i)\}_{i=1}^{n}\) drawn from \(P\). Given a loss \(L\), three distinct quantities are routinely conflated.
\[ \begin{aligned} \textbf{Training (apparent) error: }\quad &\overline{\mathrm{err}}=\frac{1}{n}\sum_{i=1}^{n}L\big(y_i,\hat f(x_i)\big)\\[2mm] \textbf{Conditional test error: }\quad &\mathrm{Err}_{\mathcal{T}}=\mathbb{E}_{(X,Y)\sim P}\Big[L\big(Y,\hat f(X)\big)\ \Big|\ \mathcal{T}\Big]\\[2mm] \textbf{Expected test error: }\quad &\mathrm{Err}=\mathbb{E}_{\mathcal{T}}\big[\mathrm{Err}_{\mathcal{T}}\big] \end{aligned} \]
\(\overline{\mathrm{err}}\) scores the model on the data that produced it. \(\mathrm{Err}_{\mathcal{T}}\) is what you actually want: the error of this particular fitted model on new data. \(\mathrm{Err}\) averages that over the randomness in the training sample too.
Common misconception: “cross-validation estimates the error of my model.” It does not, quite. Cross-validation estimates \(\mathrm{Err}\), the error of the fitting procedure, averaged over training sets, rather than \(\mathrm{Err}_{\mathcal{T}}\), the error of the one model you actually have (Hastie, Tibshirani & Friedman, ESL, §7.12). Each fold fits a different model on a different subset, and their errors are averaged.
The distinction matters when it is the deployed model whose performance you must certify. For that, a genuinely held-out set is the only direct estimate, and cross-validation is the tool for choosing the procedure rather than for certifying the artifact.
set.seed(11)
sim_errors <- function(n = 60, p = 8, sigma = 1, n_big = 20000, reps = 300) {
beta <- c(2, -1.5, 1, rep(0, p - 3))
out <- replicate(reps, {
X <- matrix(rnorm(n * p), n, p)
y <- as.vector(X %*% beta) + rnorm(n, sd = sigma)
fit <- lm(y ~ X)
# Training error on the fitting sample
tr <- mean(residuals(fit)^2)
# Conditional test error: THIS model on a very large fresh sample
Xb <- matrix(rnorm(n_big * p), n_big, p)
yb <- as.vector(Xb %*% beta) + rnorm(n_big, sd = sigma)
te <- mean((yb - cbind(1, Xb) %*% coef(fit))^2)
c(train = tr, cond_test = te)
})
c(mean_training_error = mean(out["train", ]),
mean_conditional_test_error = mean(out["cond_test", ]),
sd_of_conditional_test_error = sd(out["cond_test", ]),
irreducible = sigma^2)
}
round(sim_errors(), 4)#> mean_training_error mean_conditional_test_error
#> 0.8719 1.1780
#> sd_of_conditional_test_error irreducible
#> 0.0815 1.0000
Three facts to read off. The training error is below the irreducible noise \(\sigma^2\), impossible for genuine predictive error, and the signature of fitting the noise. The conditional test error is above it. And that conditional error has real spread across training samples, which is what \(\mathrm{Err}\) averages away.
The gap between \(\overline{\mathrm{err}}\) and the test error has a name and a closed form.
Define the in-sample error, the error at the same \(x_i\) but with fresh responses \(Y_i^{\text{new}}\) drawn from \(P(Y\mid x_i)\):
\[\mathrm{Err}_{\mathrm{in}}=\frac{1}{n}\sum_{i=1}^{n}\mathbb{E}_{Y^{\text{new}}}\Big[L\big(Y_i^{\text{new}},\hat f(x_i)\big)\ \Big|\ \mathcal{T}\Big].\]
The optimism is \(\mathrm{op}=\mathrm{Err}_{\mathrm{in}}-\overline{\mathrm{err}}\), and its expectation has a remarkably clean form.
Expected optimism. For squared-error loss (and, more generally, for log-likelihood and 0–1 loss), \[\boxed{\;\mathbb{E}_{\mathbf y}\big[\mathrm{op}\big]=\frac{2}{n}\sum_{i=1}^{n}\operatorname{Cov}\big(\hat y_i,\,y_i\big)\;}\]
Sketch. Expand \(\mathbb{E}[(y_i^{\text{new}}-\hat y_i)^2]-\mathbb{E}[(y_i-\hat y_i)^2]\). The squared terms in \(\hat y_i\) cancel, \(y_i^{\text{new}}\) is independent of \(\hat y_i\) so its cross term vanishes, and what remains is \(2\big(\mathbb{E}[y_i\hat y_i]-\mathbb{E}[y_i]\mathbb{E}[\hat y_i]\big)=2\operatorname{Cov}(\hat y_i,y_i)\). \(\blacksquare\)
The optimism is exactly twice the covariance between each fitted value and its own response, summed and scaled. The harder a model works to match its own \(y_i\), the more optimistic its training error.
For a linear fit with \(d\) effective parameters and homoscedastic noise \(\sigma^2\), \(\sum_i\operatorname{Cov}(\hat y_i,y_i)=d\sigma^2\), giving
\[\boxed{\;\mathbb{E}\big[\mathrm{Err}_{\mathrm{in}}\big]=\mathbb{E}\big[\overline{\mathrm{err}}\big]+\frac{2d\sigma^2}{n}\;}\]
set.seed(21)
verify_optimism <- function(n, p, sigma = 1, reps = 4000) {
beta <- c(2, -1.5, 1, rep(0, max(0, p - 3)))[1:p]
res <- replicate(reps, {
X <- matrix(rnorm(n * p), n, p)
y <- as.vector(X %*% beta) + rnorm(n, sd = sigma)
H <- cbind(1, X); fit <- lm.fit(H, y)
yhat <- fit$fitted.values
ynew <- as.vector(X %*% beta) + rnorm(n, sd = sigma) # SAME x, fresh y
c(err_bar = mean((y - yhat)^2), err_in = mean((ynew - yhat)^2))
})
d <- p + 1 # intercept counts
c(n = n, p = p,
observed_optimism = mean(res["err_in", ] - res["err_bar", ]),
theoretical_2d_sigma2_over_n = 2 * d * sigma^2 / n)
}
as.data.frame(do.call(rbind, list(
verify_optimism(50, 5), verify_optimism(50, 15),
verify_optimism(200, 5), verify_optimism(200, 15)))) |>
mutate(across(where(is.numeric), \(z) round(z, 4)))#> n p observed_optimism theoretical_2d_sigma2_over_n
#> 1 50 5 0.2388 0.24
#> 2 50 15 0.6461 0.64
#> 3 200 5 0.0638 0.06
#> 4 200 15 0.1592 0.16
Observed and theoretical agree to three decimals across all four configurations. Optimism grows linearly in the number of parameters and falls as \(1/n\), which is why training error is most misleading exactly where data are scarce and models are rich.
That is a surface over \((n,p)\):
n_grid <- round(seq(20, 400, length.out = 40))
p_grid <- 1:40
sigma2 <- 1
Zop <- outer(p_grid, n_grid, function(p, n) 2 * (p + 1) * sigma2 / n)
plot_ly(x = n_grid, y = p_grid, z = Zop, type = "surface",
colorscale = "Inferno", reversescale = TRUE,
colorbar = list(title = "E[optimism]")) |>
layout(title = "Expected optimism of the training error, 2(p+1)sigma^2 / n",
scene = list(xaxis = list(title = "Sample size n"),
yaxis = list(title = "Parameters p"),
zaxis = list(title = "Optimism")))Rotate toward the small-\(n\), large-\(p\) corner. The surface climbs steeply, and that corner is where most biomedical studies sit.
If the optimism can be estimated, it can be added back. That single idea generates the whole family of information criteria.
\[ \begin{aligned} \textbf{Mallows' } C_p:\quad & C_p=\overline{\mathrm{err}}+\frac{2d\hat\sigma^2}{n}\\[1mm] \textbf{AIC:}\quad & \mathrm{AIC}=-\frac{2}{n}\,\mathrm{loglik}+\frac{2d}{n}\\[1mm] \textbf{BIC:}\quad & \mathrm{BIC}=-2\,\mathrm{loglik}+d\log n \end{aligned} \]
\(C_p\) is the optimism identity written as an estimator. AIC is its log-likelihood analogue. BIC replaces the penalty \(2d\) with \(d\log n\), so for \(n>e^2\approx7.4\) it penalizes complexity more heavily, BIC is consistent (it selects the true model as \(n\to\infty\) when the true model is in the candidate set), while AIC is efficient (it minimizes prediction error asymptotically when it is not).
Effective degrees of freedom generalizes \(d\) beyond parameter counting:
\[\boxed{\;\mathrm{df}(\hat f)=\frac{1}{\sigma^2}\sum_{i=1}^{n}\operatorname{Cov}(\hat y_i,y_i)\;}\]
For any linear smoother \(\hat{\mathbf y}=S\mathbf y\), this is \(\mathrm{df}=\operatorname{tr}(S)\), the trace of the hat matrix. For OLS, \(\operatorname{tr}(H)=p+1\), recovering the parameter count. For ridge regression it is \(\sum_j\frac{d_j^2}{d_j^2+\lambda}\), a continuous function of \(\lambda\) that falls smoothly from \(p\) to 0, so complexity is not a count but a dial.
set.seed(31)
n_df <- 100; p_df <- 20
Xdf <- scale(matrix(rnorm(n_df * p_df), n_df, p_df))
sv <- svd(Xdf)
lambdas <- 10^seq(-3, 4, length.out = 60)
edf <- vapply(lambdas, \(l) sum(sv$d^2 / (sv$d^2 + l)), numeric(1))
# Verify against the covariance definition by simulation at one lambda
lam0 <- 10
beta0 <- c(rep(1, 5), rep(0, p_df - 5))
S0 <- Xdf %*% solve(crossprod(Xdf) + lam0 * diag(p_df)) %*% t(Xdf)
sim_cov <- rowMeans(replicate(3000, {
y <- as.vector(Xdf %*% beta0) + rnorm(n_df)
(S0 %*% y) * y
})) - rowMeans(replicate(3000, {
y <- as.vector(Xdf %*% beta0) + rnorm(n_df); S0 %*% y
})) * as.vector(Xdf %*% beta0)
c(trace_of_smoother = round(sum(diag(S0)), 4),
formula_sum_d2_over_d2_plus_lambda = round(sum(sv$d^2 / (sv$d^2 + lam0)), 4),
simulated_covariance_sum = round(sum(sim_cov), 3))#> trace_of_smoother formula_sum_d2_over_d2_plus_lambda
#> 17.7906 17.7906
#> simulated_covariance_sum
#> 19.1780
ggplot(data.frame(lambda = lambdas, edf = edf), aes(lambda, edf)) +
geom_line(linewidth = 1, color = "steelblue") +
geom_hline(yintercept = p_df, linetype = "dashed", color = "grey45") +
annotate("text", x = 1e-3, y = p_df - 0.9, hjust = 0, size = 3.2,
color = "grey35", label = "OLS: df = p") +
scale_x_log10() +
labs(title = "Effective degrees of freedom for ridge regression",
subtitle = expression(df(lambda) == sum(d[j]^2/(d[j]^2 + lambda))~", a continuous dial from p down to 0"),
x = expression(lambda~"(log scale)"), y = "Effective df") +
theme_dspa()# --- Interactive equivalent ------------------------------------------------
plot_ly(x = ~lambdas, y = ~edf, type = "scatter", mode = "lines",
name = "Effective df") |>
add_lines(x = range(lambdas), y = c(p_df, p_df), name = "OLS df = p",
line = list(dash = "dash", color = "gray")) |>
layout(title = "Effective degrees of freedom vs. ridge penalty",
xaxis = list(title = "lambda", type = "log"),
yaxis = list(title = "Effective df"),
legend = list(orientation = "h"))The trace, the closed-form sum, and the simulated covariance all agree. This is why “number of parameters” is the wrong notion of complexity for regularized, ensemble, or early-stopped models, and why resampling, which requires no such count, is the general tool.
Fix a positive class. For binary predictions:
| Reference: Positive | Reference: Negative | ||
|---|---|---|---|
| Predicted: Positive | \(TP\) | \(FP\) | \(\mathrm{PPV}=\frac{TP}{TP+FP}\) |
| Predicted: Negative | \(FN\) | \(TN\) | \(\mathrm{NPV}=\frac{TN}{TN+FN}\) |
| \(\mathrm{Sens}=\frac{TP}{TP+FN}\) | \(\mathrm{Spec}=\frac{TN}{TN+FP}\) |
caret::confusionMatrix(data, reference)takes predictions first. The printed matrix places predictions in rows and truth in columns. Reversing the arguments transposes the matrix, which exchanges sensitivity with specificity and PPV with NPV, silently, because the output still looks entirely plausible. Always passpositive =explicitly too; otherwisecaretuses the first factor level, which may not be the class you mean.
set.seed(41)
truth <- factor(rep(c("pos", "neg"), times = c(30, 70)), levels = c("neg", "pos"))
pred <- factor(c(rep("pos", 24), rep("neg", 6),
rep("pos", 14), rep("neg", 56)), levels = c("neg", "pos"))
right <- caret::confusionMatrix(data = pred, reference = truth, positive = "pos")
wrong <- caret::confusionMatrix(data = truth, reference = pred, positive = "pos")
rbind(`correct order (pred, truth)` = right$byClass[c("Sensitivity", "Specificity",
"Pos Pred Value", "Neg Pred Value")],
`reversed order (truth, pred)` = wrong$byClass[c("Sensitivity", "Specificity",
"Pos Pred Value", "Neg Pred Value")]) |>
round(4)#> Sensitivity Specificity Pos Pred Value
#> correct order (pred, truth) 0.8000 0.8000 0.6316
#> reversed order (truth, pred) 0.6316 0.9032 0.8000
#> Neg Pred Value
#> correct order (pred, truth) 0.9032
#> reversed order (truth, pred) 0.8000
Sensitivity and PPV have swapped places. Neither output signals an error.
The single most useful organizing idea for this whole family: every rate is a conditional probability, and what distinguishes them is what they condition on.
\[ \begin{aligned} \textbf{Column rates (condition on the TRUTH):}\quad &\alpha=\text{Type I}=P(\hat Y{=}1\mid Y{=}0)=\tfrac{FP}{FP+TN}=1-\mathrm{Spec}\\ &\beta=\text{Type II}=P(\hat Y{=}0\mid Y{=}1)=\tfrac{FN}{TP+FN}=1-\mathrm{Sens}\\[3mm] \textbf{Row rates (condition on the PREDICTION):}\quad &\mathrm{FDR}=P(Y{=}0\mid \hat Y{=}1)=\tfrac{FP}{TP+FP}=1-\mathrm{PPV}\\ &\mathrm{FOR}=P(Y{=}1\mid \hat Y{=}0)=\tfrac{FN}{TN+FN}=1-\mathrm{NPV} \end{aligned} \]
Common misconception: “\(1-\text{precision}\) is the Type I error rate.” It is the false discovery rate. Type I error conditions on the case being truly negative; FDR conditions on the case being predicted positive. Bayes’ rule connects them through the prevalence \(\pi=P(Y{=}1)\):
\[\mathrm{PPV}=\frac{\mathrm{Sens}\cdot\pi}{\mathrm{Sens}\cdot\pi+(1-\mathrm{Spec})(1-\pi)} .\]
They coincide only under very particular conditions, and they diverge dramatically when the positive class is rare, which is the situation in screening, rare-disease diagnosis, and fraud detection. Sensitivity and specificity are properties of the test; PPV and NPV are properties of the test in a population.
sens_fixed <- 0.95; spec_fixed <- 0.95
prev <- 10^seq(-4, -0.3, length.out = 200)
ppv <- sens_fixed * prev / (sens_fixed * prev + (1 - spec_fixed) * (1 - prev))
data.frame(prevalence = prev, PPV = ppv,
`Type I error` = 1 - spec_fixed, check.names = FALSE) |>
pivot_longer(-prevalence, names_to = "quantity", values_to = "value") |>
ggplot(aes(prevalence, value, color = quantity)) +
geom_line(linewidth = 1) +
scale_x_log10(labels = scales::percent) +
scale_y_continuous(labels = scales::percent) +
scale_color_manual(values = c(PPV = "#3B7DD8", `Type I error` = "#D8433B")) +
labs(title = "A test with 95% sensitivity and 95% specificity",
subtitle = "Type I error is fixed at 5% by construction; PPV collapses as the disease becomes rare",
x = "Prevalence (log scale)", y = NULL, color = NULL) +
theme_dspa()ppv_at <- function(p, se = 0.95, sp = 0.95) se * p / (se * p + (1 - sp) * (1 - p))
data.frame(prevalence = c(0.5, 0.1, 0.01, 0.001),
sensitivity = 0.95, specificity = 0.95,
type_I_error = 0.05,
PPV = round(ppv_at(c(0.5, 0.1, 0.01, 0.001)), 4),
FDR = round(1 - ppv_at(c(0.5, 0.1, 0.01, 0.001)), 4))#> prevalence sensitivity specificity type_I_error PPV FDR
#> 1 0.500 0.95 0.95 0.05 0.9500 0.0500
#> 2 0.100 0.95 0.95 0.05 0.6786 0.3214
#> 3 0.010 0.95 0.95 0.05 0.1610 0.8390
#> 4 0.001 0.95 0.95 0.05 0.0187 0.9813
At a prevalence of 1 in 1,000, a test with 95% specificity, Type I error fixed at exactly 5%, produces a false discovery rate above 98%. The test has not changed; only what we condition on has.
binary_counts <- function(pred, truth, positive) {
pred <- as.character(pred); truth <- as.character(truth)
c(TP = sum(pred == positive & truth == positive),
FP = sum(pred == positive & truth != positive),
FN = sum(pred != positive & truth == positive),
TN = sum(pred != positive & truth != positive))
}
binary_metrics <- function(pred, truth, positive) {
k <- binary_counts(pred, truth, positive); n <- sum(k)
sens <- k[["TP"]] / (k[["TP"]] + k[["FN"]])
spec <- k[["TN"]] / (k[["TN"]] + k[["FP"]])
ppv <- k[["TP"]] / (k[["TP"]] + k[["FP"]])
npv <- k[["TN"]] / (k[["TN"]] + k[["FN"]])
acc <- (k[["TP"]] + k[["TN"]]) / n
p_e <- ((k[["TP"]] + k[["FP"]]) * (k[["TP"]] + k[["FN"]]) +
(k[["TN"]] + k[["FN"]]) * (k[["TN"]] + k[["FP"]])) / n^2
mcc_den <- sqrt(prod(c(k[["TP"]] + k[["FP"]], k[["TP"]] + k[["FN"]],
k[["TN"]] + k[["FP"]], k[["TN"]] + k[["FN"]])))
c(k, n = n, prevalence = (k[["TP"]] + k[["FN"]]) / n,
accuracy = acc, NIR = max(mean(truth == positive), 1 - mean(truth == positive)),
sensitivity = sens, specificity = spec, PPV = ppv, NPV = npv,
type_I = 1 - spec, type_II = 1 - sens, FDR = 1 - ppv, FOR = 1 - npv,
balanced_accuracy = (sens + spec) / 2,
F1 = 2 * ppv * sens / (ppv + sens),
kappa = (acc - p_e) / (1 - p_e),
MCC = if (mcc_den > 0) (k[["TP"]] * k[["TN"]] - k[["FP"]] * k[["FN"]]) / mcc_den else NA_real_)
}
round(binary_metrics(pred, truth, "pos"), 4)#> TP FP FN TN
#> 24.0000 14.0000 6.0000 56.0000
#> n prevalence accuracy NIR
#> 100.0000 0.3000 0.8000 0.7000
#> sensitivity specificity PPV NPV
#> 0.8000 0.8000 0.6316 0.9032
#> type_I type_II FDR FOR
#> 0.2000 0.2000 0.3684 0.0968
#> balanced_accuracy F1 kappa MCC
#> 0.8000 0.7059 0.5575 0.5665
Every rate is computed from a named count vector.
Extracting counts by position from table() is fragile: a
perfectly classified fold has only one logical level, and
table(x)[1] then silently returns the wrong number.
Accuracy does not account for agreement expected by chance. Cohen’s \(\kappa\) does:
\[\boxed{\;\kappa=\frac{p_o-p_e}{1-p_e}\;},\qquad p_o=\text{observed agreement},\quad p_e=\sum_{k}\hat P(\hat Y{=}k)\,\hat P(Y{=}k).\]
\(\kappa=1\) is perfect agreement with the ground truth; \(\kappa=0\) is chance-level agreement. Note the range:
\(\kappa\) is not bounded below by zero. It reaches \(-1\) in the symmetric case and can be negative whenever agreement is worse than chance. Its attainable minimum depends on the marginals, so a \(\kappa\) of \(-0.1\) on unbalanced data may already be near the floor.
#> Reference
#> Predicted neg pos
#> neg 56 6
#> pos 14 24
n_tot <- sum(cm)
p_o <- sum(diag(cm)) / n_tot
p_e <- sum(rowSums(cm) * colSums(cm)) / n_tot^2
c(observed_agreement = round(p_o, 4),
expected_agreement = round(p_e, 4),
kappa_manual = round((p_o - p_e) / (1 - p_e), 4),
kappa_caret = round(caret::confusionMatrix(cm)$overall[["Kappa"]], 4))#> observed_agreement expected_agreement kappa_manual kappa_caret
#> 0.8000 0.5480 0.5575 0.5575
Note that \(p_o\) is the accuracy (0.80 here), while \(\kappa\) is a different number entirely. Reporting one as the other is a common slip.
Two classifiers with identical sensitivity and specificity can have very different \(\kappa\) if the base rate differs.
kappa_from <- function(sens, spec, prev, n = 10000) {
TP <- n * prev * sens; FN <- n * prev * (1 - sens)
TN <- n * (1 - prev) * spec; FP <- n * (1 - prev) * (1 - spec)
p_o <- (TP + TN) / n
p_e <- ((TP + FP) * (TP + FN) + (TN + FN) * (TN + FP)) / n^2
(p_o - p_e) / (1 - p_e)
}
pv <- seq(0.02, 0.98, length.out = 200)
kp <- data.frame(prevalence = pv,
`sens = spec = 0.90` = kappa_from(0.90, 0.90, pv),
`sens = spec = 0.75` = kappa_from(0.75, 0.75, pv),
check.names = FALSE)
kp |> pivot_longer(-prevalence, names_to = "test", values_to = "kappa") |>
ggplot(aes(prevalence, kappa, color = test)) +
geom_line(linewidth = 1) +
scale_color_manual(values = c("#3B7DD8", "#D8433B")) +
labs(title = "Kappa depends on prevalence even when the test does not",
subtitle = "Sensitivity and specificity are held fixed along each curve; only the base rate changes",
x = "Prevalence", y = expression(kappa), color = NULL) +
theme_dspa()data.frame(prevalence = c(0.5, 0.2, 0.05, 0.01),
sensitivity = 0.90, specificity = 0.90,
kappa = round(kappa_from(0.90, 0.90, c(0.5, 0.2, 0.05, 0.01)), 4))#> prevalence sensitivity specificity kappa
#> 1 0.50 0.9 0.9 0.8000
#> 2 0.20 0.9 0.9 0.7191
#> 3 0.05 0.9 0.9 0.4318
#> 4 0.01 0.9 0.9 0.1367
The same test, 90% sensitive, 90% specific at every point, has \(\kappa=0.80\) at balanced prevalence and \(\kappa\) near 0.15 at 1% prevalence. \(\kappa\) is not a property of the classifier alone.
paradox <- function(tab) {
n <- sum(tab); p_o <- sum(diag(tab)) / n
p_e <- sum(rowSums(tab) * colSums(tab)) / n^2
c(accuracy = round(p_o, 4), expected = round(p_e, 4),
kappa = round((p_o - p_e) / (1 - p_e), 4))
}
A <- matrix(c(45, 15, 25, 15), 2, byrow = TRUE) # balanced marginals
B <- matrix(c(86, 4, 10, 0), 2, byrow = TRUE) # very skewed marginals
rbind(`balanced marginals` = paradox(A),
`skewed marginals` = paradox(B))#> accuracy expected kappa
#> balanced marginals 0.60 0.540 0.1304
#> skewed marginals 0.86 0.868 -0.0606
The second table has higher accuracy (0.86 versus 0.60) and a lower \(\kappa\), because heavily skewed marginals make chance agreement itself very high, leaving little room above it. This is the Feinstein–Cicchetti paradox.
The standard interpretive bands are Landis & Koch (1977): poor \(<0.20\), fair \(0.20\)–\(0.40\), moderate \(0.40\)–\(0.60\), good \(0.60\)–\(0.80\), very good \(0.80\)–\(1.00\). Use them with the prevalence in hand, and prefer the Matthews correlation coefficient when marginals are skewed, MCC is the Pearson correlation between the binary prediction and truth vectors and is symmetric in the two classes.
Weighted kappa is for ordinal outcomes. It assigns partial credit for near-misses, which requires an ordering on the categories. With two nominal classes there are no near-misses, so weighting has nothing to express. The weight matrix must also encode disagreement, with zeros on the diagonal; a matrix with non-zero diagonal entries does not define a valid scheme, and the resulting “kappa” can leave \([-1,1]\) for that reason rather than any property of the statistic.
Every plug-in classifier produces a score \(\hat p(x)\) and then thresholds it. Metrics computed at one threshold describe one operating point; curves describe all of them.
The ROC curve traces \((\text{FPR},\text{TPR})\) as the threshold sweeps. Its area has an exact probabilistic meaning:
\[\boxed{\;\mathrm{AUC}=P\big(\hat p(X^{+})>\hat p(X^{-})\big)\;}\]
the probability that a randomly chosen positive scores above a randomly chosen negative. It is threshold-free and prevalence-invariant: change the class balance and the ROC curve does not move.
The PR curve traces \((\text{Recall},\text{Precision})\). Precision is a row rate, so it does depend on prevalence, and that is the point.
Common misconception: “AUC of 0.9 means the model is good.” ROC AUC is prevalence-invariant, which is a virtue when comparing tests and a liability when deploying one. Under heavy imbalance, the FPR denominator \(FP+TN\) is dominated by \(TN\), so even a large number of false positives moves the ROC curve very little. The precision–recall curve, whose denominator is \(TP+FP\), registers them immediately.
The chance levels differ too: ROC AUC has chance level \(0.5\) regardless of balance, while PR-AUC has chance level equal to the positive prevalence. A PR-AUC of 0.3 is excellent at 1% prevalence and terrible at 50%. Always report the prevalence alongside.
library(PRROC)
set.seed(51)
make_scores <- function(n, prev, sep = 1.2) {
y <- rbinom(n, 1, prev)
s <- rnorm(n, mean = ifelse(y == 1, sep, 0))
list(y = y, s = s)
}
curves <- bind_rows(lapply(c(0.5, 0.1, 0.02), function(pv) {
d <- make_scores(4000, pv)
r <- PRROC::roc.curve(scores.class0 = d$s[d$y == 1],
scores.class1 = d$s[d$y == 0], curve = TRUE)
p <- PRROC::pr.curve(scores.class0 = d$s[d$y == 1],
scores.class1 = d$s[d$y == 0], curve = TRUE)
bind_rows(
data.frame(x = r$curve[, 1], y = r$curve[, 2], type = "ROC",
panel = sprintf("prevalence %.0f%%\nROC-AUC %.3f | PR-AUC %.3f",
100 * pv, r$auc, p$auc.integral)),
data.frame(x = p$curve[, 1], y = p$curve[, 2], type = "Precision-Recall",
panel = sprintf("prevalence %.0f%%\nROC-AUC %.3f | PR-AUC %.3f",
100 * pv, r$auc, p$auc.integral)))
}))
ggplot(curves, aes(x, y, color = type)) +
geom_line(linewidth = 0.9) +
facet_grid(type ~ panel, switch = "y") +
scale_color_manual(values = c(ROC = "#3B7DD8",
`Precision-Recall` = "#D8433B"), guide = "none") +
coord_fixed() +
labs(title = "The same classifier at three prevalences",
subtitle = "ROC is unchanged; the PR curve collapses as positives become rare",
x = NULL, y = NULL) +
theme_dspa(9)The top row barely moves while the bottom row collapses. The classifier is identical in all three columns, only the base rate differs.
# --- Interactive equivalent ------------------------------------------------
d <- make_scores(4000, 0.1)
r <- PRROC::roc.curve(scores.class0 = d$s[d$y == 1],
scores.class1 = d$s[d$y == 0], curve = TRUE)
p <- PRROC::pr.curve(scores.class0 = d$s[d$y == 1],
scores.class1 = d$s[d$y == 0], curve = TRUE)
plot_ly() |>
add_lines(x = r$curve[, 1], y = r$curve[, 2],
name = sprintf("ROC (AUC = %.3f)", r$auc)) |>
add_lines(x = c(0, 1), y = c(0, 1), name = "Chance",
line = list(dash = "dash", color = "black")) |>
layout(title = "ROC curve",
xaxis = list(title = "False positive rate", scaleanchor = "y"),
yaxis = list(title = "True positive rate"),
legend = list(orientation = "h"))
plot_ly() |>
add_lines(x = p$curve[, 1], y = p$curve[, 2],
name = sprintf("PR (AUC = %.3f)", p$auc.integral)) |>
add_lines(x = c(0, 1), y = rep(mean(d$y), 2), name = "Chance = prevalence",
line = list(dash = "dash", color = "black")) |>
layout(title = "Precision-Recall curve",
xaxis = list(title = "Recall"), yaxis = list(title = "Precision"),
legend = list(orientation = "h"))
ROCR::performance(pred, "auc")computes the ROC AUC. Displaying it next to a precision–recall plot reports the wrong quantity. UsePRROC::pr.curve()oryardstick::pr_auc()for the area under the PR curve.
set.seed(53)
d <- make_scores(3000, 0.05)
r <- PRROC::roc.curve(scores.class0 = d$s[d$y == 1], scores.class1 = d$s[d$y == 0])
p <- PRROC::pr.curve(scores.class0 = d$s[d$y == 1], scores.class1 = d$s[d$y == 0])
c(ROC_AUC = round(r$auc, 4), PR_AUC = round(p$auc.integral, 4),
prevalence = round(mean(d$y), 4),
PR_chance_level = round(mean(d$y), 4), ROC_chance_level = 0.5)#> ROC_AUC PR_AUC prevalence PR_chance_level
#> 0.8080 0.2154 0.0513 0.0513
#> ROC_chance_level
#> 0.5000
AUC measures ranking. It says nothing about whether a predicted probability of 0.8 corresponds to an event that happens 80% of the time. Those are separate properties, and a model can have one without the other.
Common misconception: “a high AUC means the predicted probabilities are trustworthy.” AUC is invariant to any strictly increasing transformation of the scores. Replace every \(\hat p\) by \(\hat p^{10}\) and the AUC is unchanged, while every probability is now wildly wrong. If the output feeds a threshold chosen by cost, a risk communication, or a downstream calculation, ranking is not enough.
A model is calibrated if, among cases assigned probability \(q\), the event occurs a fraction \(q\) of the time:
\[\boxed{\;P\big(Y=1\ \big|\ \hat p(X)=q\big)=q\qquad\text{for all }q\in[0,1]\;}\]
Bin the predictions, plot observed frequency against mean predicted probability, and compare to the diagonal. The parametric summary is the calibration intercept and slope from the logistic recalibration model
\[\operatorname{logit}\big(P(Y=1)\big)=\alpha+\gamma\,\operatorname{logit}(\hat p),\]
where perfect calibration is \(\alpha=0\), \(\gamma=1\). A slope \(\gamma<1\) means the predictions are too extreme (over-confident); \(\gamma>1\) means they are too conservative; \(\alpha\ne0\) means a systematic shift in the overall risk level.
set.seed(61)
n_cal <- 4000
x_cal <- rnorm(n_cal)
p_true <- plogis(1.2 * x_cal)
y_cal <- rbinom(n_cal, 1, p_true)
# Three score vectors with IDENTICAL ranking, hence identical AUC
p_good <- p_true
p_over <- plogis(3.0 * qlogis(p_true)) # over-confident: slope < 1
p_shift <- plogis(qlogis(p_true) + 1.2) # shifted: intercept != 0
auc_of <- function(p) as.numeric(pROC::auc(pROC::roc(y_cal, p, quiet = TRUE)))
c(AUC_well_calibrated = round(auc_of(p_good), 4),
AUC_over_confident = round(auc_of(p_over), 4),
AUC_shifted = round(auc_of(p_shift), 4))#> AUC_well_calibrated AUC_over_confident AUC_shifted
#> 0.7795 0.7795 0.7795
All three AUCs are identical to four decimals, because all three are monotone transformations of the same score.
calib_summary <- function(p, y, bins = 12) {
eps <- 1e-12; p <- pmin(pmax(p, eps), 1 - eps)
fit <- glm(y ~ qlogis(p), family = binomial())
c(Brier = mean((p - y)^2),
log_loss = -mean(y * log(p) + (1 - y) * log(1 - p)),
calib_intercept = unname(coef(fit)[1]),
calib_slope = unname(coef(fit)[2]),
mean_predicted = mean(p), observed_rate = mean(y))
}
rbind(`well calibrated` = calib_summary(p_good, y_cal),
`over-confident` = calib_summary(p_over, y_cal),
`shifted` = calib_summary(p_shift, y_cal)) |> round(4)#> Brier log_loss calib_intercept calib_slope mean_predicted
#> well calibrated 0.1913 0.5631 -0.0400 1.0395 0.4974
#> over-confident 0.2187 0.7444 -0.0400 0.3465 0.4979
#> shifted 0.2497 0.7079 -1.2873 1.0395 0.7168
#> observed_rate
#> well calibrated 0.4898
#> over-confident 0.4898
#> shifted 0.4898
The Brier score, log-loss, and calibration slope all separate the three models that AUC cannot tell apart.
reliability <- function(p, y, label, bins = 12) {
b <- cut(p, breaks = seq(0, 1, length.out = bins + 1), include.lowest = TRUE)
data.frame(p = p, y = y, b = b) |>
summarise(predicted = mean(p), observed = mean(y), n = dplyr::n(), .by = b) |>
filter(n >= 20) |> mutate(model = label)
}
bind_rows(reliability(p_good, y_cal, "Well calibrated"),
reliability(p_over, y_cal, "Over-confident"),
reliability(p_shift, y_cal, "Shifted")) |>
ggplot(aes(predicted, observed, color = model)) +
geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "grey50") +
geom_line(linewidth = 0.9) + geom_point(aes(size = n)) +
scale_color_manual(values = c(`Well calibrated` = "#3B7DD8",
`Over-confident` = "#D8433B",
Shifted = "#7FB069")) +
scale_size_continuous(range = c(1.2, 4.5), guide = "none") +
coord_fixed(xlim = c(0, 1), ylim = c(0, 1)) +
labs(title = "Reliability diagram: three models with identical AUC",
subtitle = "Distance from the diagonal is miscalibration, which ranking metrics cannot see",
x = "Mean predicted probability", y = "Observed frequency", color = NULL) +
theme_dspa()# --- Interactive equivalent ------------------------------------------------
rg <- reliability(p_good, y_cal, "Well calibrated")
ro <- reliability(p_over, y_cal, "Over-confident")
plot_ly() |>
add_trace(x = rg$predicted, y = rg$observed, type = "scatter",
mode = "lines+markers", name = "Well calibrated") |>
add_trace(x = ro$predicted, y = ro$observed, type = "scatter",
mode = "lines+markers", name = "Over-confident") |>
add_lines(x = c(0, 1), y = c(0, 1), name = "Perfect calibration",
line = list(dash = "dash", color = "black")) |>
layout(title = "Reliability diagram",
xaxis = list(title = "Predicted probability", scaleanchor = "y"),
yaxis = list(title = "Observed frequency"),
legend = list(orientation = "h"))Miscalibration is a two-dimensional phenomenon, its magnitude depends on both the predicted probability and the strength of the distortion, so it is worth rotating:
p_grid <- seq(0.01, 0.99, length.out = 60)
gamma_grid <- seq(0.3, 3.0, length.out = 60)
# Observed frequency when predictions are distorted with slope gamma
Zcal <- outer(gamma_grid, p_grid, function(g, q) plogis(qlogis(q) / g))
plot_ly(x = p_grid, y = gamma_grid, z = Zcal, type = "surface",
colorscale = "RdBu", reversescale = TRUE,
colorbar = list(title = "Observed\nfrequency")) |>
add_trace(x = p_grid, y = rep(1, length(p_grid)), z = p_grid,
type = "scatter3d", mode = "lines", name = "Perfect calibration",
line = list(width = 8, color = "black")) |>
layout(title = "Observed frequency as a function of predicted probability and calibration slope",
scene = list(xaxis = list(title = "Predicted probability"),
yaxis = list(title = "Calibration slope gamma"),
zaxis = list(title = "Observed frequency")))The black ridge at \(\gamma=1\) is the diagonal. Away from it the surface bends: \(\gamma<1\) pulls observed frequencies toward the middle (predictions too extreme), \(\gamma>1\) pushes them outward.
A scoring rule is proper if it is minimized in expectation by reporting the true probability, and strictly proper if that minimizer is unique. Accuracy is not proper, it can be improved by distorting probabilities toward 0 and 1.
\[ \begin{aligned} \textbf{Brier score: }\quad &\mathrm{BS}=\frac1n\sum_{i=1}^{n}\big(\hat p_i-y_i\big)^2 &&\text{(quadratic, strictly proper)}\\[1mm] \textbf{Log-loss: }\quad &\mathrm{LL}=-\frac1n\sum_{i=1}^{n}\Big[y_i\log\hat p_i+(1-y_i)\log(1-\hat p_i)\Big] &&\text{(logarithmic, strictly proper)} \end{aligned} \]
Log-loss is unbounded, a confident wrong prediction (\(\hat p\to0\) when \(y=1\)) costs infinitely, so it punishes over-confidence far more harshly than Brier.
Murphy’s decomposition splits the Brier score into three interpretable pieces:
\[\boxed{\;\mathrm{BS}=\underbrace{\frac1n\sum_k n_k(\bar p_k-\bar y_k)^2}_{\text{reliability (calibration), lower is better}} -\underbrace{\frac1n\sum_k n_k(\bar y_k-\bar y)^2}_{\text{resolution (discrimination), higher is better}} +\underbrace{\bar y(1-\bar y)}_{\text{uncertainty (irreducible)}}\;}\]
The third term depends only on the base rate, it is the Brier score of the best constant predictor and cannot be improved by any model.
brier_decomp <- function(p, y, bins = 15) {
b <- cut(p, breaks = seq(0, 1, length.out = bins + 1), include.lowest = TRUE)
d <- data.frame(p = p, y = y, b = b) |>
summarise(n = dplyr::n(), pbar = mean(p), ybar = mean(y), .by = b)
N <- length(y); ybar_all <- mean(y)
rel <- sum(d$n * (d$pbar - d$ybar)^2) / N
res <- sum(d$n * (d$ybar - ybar_all)^2) / N
unc <- ybar_all * (1 - ybar_all)
c(reliability = rel, resolution = res, uncertainty = unc,
reconstructed = rel - res + unc, actual_brier = mean((p - y)^2))
}
rbind(`well calibrated` = brier_decomp(p_good, y_cal),
`over-confident` = brier_decomp(p_over, y_cal),
`shifted` = brier_decomp(p_shift, y_cal)) |> round(5)#> reliability resolution uncertainty reconstructed actual_brier
#> well calibrated 0.00086 0.05892 0.24989 0.19184 0.19130
#> over-confident 0.02632 0.05643 0.24989 0.21979 0.21866
#> shifted 0.05911 0.05892 0.24989 0.25008 0.24966
The decomposition reconstructs the Brier score exactly. Read the columns: all three models have similar resolution (they rank equally well, as the AUCs showed) and very different reliability. That is the distinction the whole section is about, now quantified.
Miscalibration is fixable without touching the model, by learning a monotone map from scores to probabilities. Two standard methods:
Platt scaling fits a one-parameter logistic model \(P(Y=1\mid s)=\sigma(a+bs)\) on held-out scores. Parametric, needs little data, assumes a sigmoidal distortion.
Isotonic regression fits the best non-decreasing step function by pool-adjacent-violators. Non-parametric and more flexible, but it needs more data and can overfit in small samples.
The recalibration map must be fitted on data the model did not train on. Fitting it on the training predictions learns the model’s in-sample over-confidence, which is not the distortion present out of sample.
set.seed(63)
idx_cal <- sample(n_cal, n_cal / 2) # calibration set
idx_ev <- setdiff(seq_len(n_cal), idx_cal) # evaluation set
# Platt: logistic on the LOGIT of the raw score, fitted on the calibration half
platt <- glm(y_cal[idx_cal] ~ qlogis(p_over[idx_cal]), family = binomial())
p_platt <- plogis(predict(platt, newdata = data.frame(
`qlogis(p_over[idx_cal])` = qlogis(p_over[idx_ev]), check.names = FALSE)))
p_platt <- plogis(coef(platt)[1] + coef(platt)[2] * qlogis(p_over[idx_ev]))
# Isotonic: monotone step function, also fitted on the calibration half
iso <- isoreg(p_over[idx_cal], y_cal[idx_cal])
iso_fn <- approxfun(iso$x, iso$yf, method = "linear", rule = 2)
p_iso <- pmin(pmax(iso_fn(p_over[idx_ev]), 1e-6), 1 - 1e-6)
rbind(`uncalibrated` = calib_summary(p_over[idx_ev], y_cal[idx_ev]),
`Platt scaling` = calib_summary(p_platt, y_cal[idx_ev]),
`isotonic` = calib_summary(p_iso, y_cal[idx_ev])) |>
round(4)#> Brier log_loss calib_intercept calib_slope mean_predicted
#> uncalibrated 0.2180 0.7365 -0.0432 0.3511 0.4929
#> Platt scaling 0.1901 0.5602 -0.0057 1.0267 0.4868
#> isotonic 0.2988 0.8220 -0.0586 -0.0379 0.4942
#> observed_rate
#> uncalibrated 0.4855
#> Platt scaling 0.4855
#> isotonic 0.4855
bind_rows(reliability(p_over[idx_ev], y_cal[idx_ev], "Uncalibrated"),
reliability(p_platt, y_cal[idx_ev], "Platt scaling"),
reliability(p_iso, y_cal[idx_ev], "Isotonic")) |>
ggplot(aes(predicted, observed, color = model)) +
geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "grey50") +
geom_line(linewidth = 0.9) + geom_point(size = 2) +
scale_color_manual(values = c(Uncalibrated = "#D8433B",
`Platt scaling` = "#3B7DD8",
Isotonic = "#7FB069")) +
coord_fixed(xlim = c(0, 1), ylim = c(0, 1)) +
labs(title = "Recalibration on held-out data",
subtitle = "The ranking, and hence the AUC, is unchanged -- only the probabilities move",
x = "Mean predicted probability", y = "Observed frequency", color = NULL) +
theme_dspa()c(AUC_before = round(auc_of2 <- as.numeric(pROC::auc(pROC::roc(
y_cal[idx_ev], p_over[idx_ev], quiet = TRUE))), 4),
AUC_after_platt = round(as.numeric(pROC::auc(pROC::roc(
y_cal[idx_ev], p_platt, quiet = TRUE))), 4))#> AUC_before AUC_after_platt
#> 0.7814 0.7814
The AUC is unchanged, both recalibration maps are monotone, so the ranking is preserved exactly, while the Brier score and calibration slope improve substantially. Recalibration is free improvement in everything except ranking.
Metrics so far answer “how good is this model?” A different and often more useful question is “is using this model better than not using it?”
Suppose acting on a positive prediction has benefit \(B\) per true positive and cost \(C\) per false positive. A rational decision-maker acts when the predicted probability exceeds the threshold probability \(p_t=C/(B+C)\), so \(p_t\) encodes the cost ratio. The net benefit at threshold \(p_t\) is
\[\boxed{\;\mathrm{NB}(p_t)=\frac{TP}{n}-\frac{FP}{n}\cdot\frac{p_t}{1-p_t}\;}\]
measured in units of true positives per patient, with false positives discounted by the odds of the threshold (Vickers & Elkin, 2006).
Two reference strategies bracket any model:
\[\mathrm{NB}_{\text{treat all}}(p_t)=\pi-(1-\pi)\frac{p_t}{1-p_t}, \qquad \mathrm{NB}_{\text{treat none}}=0 .\]
A model is worth using only where its curve lies above both.
net_benefit <- function(p, y, thresholds = seq(0.01, 0.60, by = 0.005)) {
n <- length(y)
vapply(thresholds, function(t) {
pos <- p >= t
TP <- sum(pos & y == 1); FP <- sum(pos & y == 0)
TP / n - (FP / n) * (t / (1 - t))
}, numeric(1))
}
set.seed(65)
n_dc <- 3000
x_dc <- rnorm(n_dc)
p_dc_true <- plogis(-2.2 + 1.4 * x_dc) # ~12% prevalence
y_dc <- rbinom(n_dc, 1, p_dc_true)
p_model <- p_dc_true
p_weak <- plogis(-2.2 + 0.4 * x_dc) # poorly discriminating
th <- seq(0.01, 0.60, by = 0.005)
pi_dc <- mean(y_dc)
dc <- bind_rows(
data.frame(t = th, nb = net_benefit(p_model, y_dc, th), strategy = "Good model"),
data.frame(t = th, nb = net_benefit(p_weak, y_dc, th), strategy = "Weak model"),
data.frame(t = th, nb = pi_dc - (1 - pi_dc) * th / (1 - th), strategy = "Treat all"),
data.frame(t = th, nb = 0, strategy = "Treat none"))
ggplot(dc, aes(t, nb, color = strategy, linetype = strategy)) +
geom_line(linewidth = 0.9) +
coord_cartesian(ylim = c(-0.05, max(dc$nb) * 1.05)) +
scale_color_manual(values = c(`Good model` = "#3B7DD8", `Weak model` = "#D8433B",
`Treat all` = "grey40", `Treat none` = "black")) +
scale_linetype_manual(values = c(`Good model` = "solid", `Weak model` = "solid",
`Treat all` = "dashed", `Treat none` = "dotted")) +
labs(title = "Decision curve: is the model worth using?",
subtitle = sprintf("Prevalence %.1f%%. A model earns its place only where it rises above BOTH references",
100 * pi_dc),
x = expression("Threshold probability "*p[t]), y = "Net benefit",
color = NULL, linetype = NULL) +
theme_dspa()# --- Interactive equivalent ------------------------------------------------
plot_ly() |>
add_lines(x = th, y = net_benefit(p_model, y_dc, th), name = "Good model") |>
add_lines(x = th, y = net_benefit(p_weak, y_dc, th), name = "Weak model") |>
add_lines(x = th, y = pi_dc - (1 - pi_dc) * th / (1 - th), name = "Treat all",
line = list(dash = "dash", color = "gray")) |>
add_lines(x = th, y = rep(0, length(th)), name = "Treat none",
line = list(dash = "dot", color = "black")) |>
layout(title = "Decision curve analysis",
xaxis = list(title = "Threshold probability"),
yaxis = list(title = "Net benefit", range = c(-0.05, 0.15)),
legend = list(orientation = "h"))Net benefit depends on both the threshold and the prevalence, which makes it a surface:
prev_grid <- seq(0.02, 0.45, length.out = 40)
th_grid <- seq(0.02, 0.55, length.out = 40)
# Net benefit of a fixed-quality model (sens 0.85, spec 0.80 at each threshold's
# operating point, approximated by a bi-normal ROC) minus treat-all
Znb <- outer(prev_grid, th_grid, function(pv, t) {
sens <- 0.85; spec <- 0.80
nb_model <- pv * sens - (1 - pv) * (1 - spec) * (t / (1 - t))
nb_all <- pv - (1 - pv) * (t / (1 - t))
pmax(nb_model - pmax(nb_all, 0), -0.05)
})
plot_ly(x = th_grid, y = prev_grid, z = Znb, type = "surface",
colorscale = "RdBu",
colorbar = list(title = "NB(model)\n- NB(best reference)")) |>
layout(title = "Where does a model add net benefit over treat-all / treat-none?",
scene = list(xaxis = list(title = "Threshold probability"),
yaxis = list(title = "Prevalence"),
zaxis = list(title = "Excess net benefit")))The region where the surface is positive is where the model helps. Rotate to see that it shrinks toward the low-prevalence, low-threshold corner: when the disease is rare and the cost of missing it is high, treating everyone is hard to beat.
\[ \begin{aligned} \mathrm{RMSE}&=\sqrt{\tfrac1n\textstyle\sum_i(y_i-\hat y_i)^2} & \mathrm{MAE}&=\tfrac1n\textstyle\sum_i|y_i-\hat y_i|\\[1mm] R^2&=1-\frac{\sum_i(y_i-\hat y_i)^2}{\sum_i(y_i-\bar y)^2} & \mathrm{CCC}&=\frac{2\operatorname{Cov}(\hat y,y)}{\operatorname{Var}(\hat y)+\operatorname{Var}(y)+(\bar{\hat y}-\bar y)^2} \end{aligned} \]
Common misconception: “report the correlation between predicted and observed.” Correlation is invariant to affine transformation: \(\operatorname{cor}(a+b\hat y,\,y)=\operatorname{cor}(\hat y,y)\) for any \(b>0\). A model returning \(\hat y=100+0.01y\) correlates exactly 1 with the truth and is useless. Correlation cannot detect bias, cannot detect scale error, and is not a loss anyone minimizes.
Report RMSE and MAE in the response’s units, \(R^2\) computed on held-out data, which can be negative, informatively so, when the model is worse than the mean, and the calibration slope and intercept from regressing \(y\) on \(\hat y\), where agreement means intercept 0 and slope 1. Lin’s concordance correlation coefficient combines the last two into one number.
reg_metrics <- function(pred, obs) {
cal <- coef(lm(obs ~ pred))
c(RMSE = sqrt(mean((pred - obs)^2)),
MAE = mean(abs(pred - obs)),
R2 = 1 - sum((pred - obs)^2) / sum((obs - mean(obs))^2),
correlation = cor(pred, obs),
calib_intercept = unname(cal[1]), calib_slope = unname(cal[2]),
CCC = 2 * cov(pred, obs) /
(var(pred) + var(obs) + (mean(pred) - mean(obs))^2))
}
set.seed(67)
y_reg <- rnorm(500, 50, 10)
rbind(`good model` = reg_metrics(y_reg + rnorm(500, 0, 3), y_reg),
`biased by +20` = reg_metrics(y_reg + 20, y_reg),
`wrong scale` = reg_metrics(100 + 0.01 * y_reg, y_reg),
`mean predictor` = reg_metrics(rep(mean(y_reg), 500), y_reg)) |>
round(4)#> RMSE MAE R2 correlation calib_intercept calib_slope
#> good model 2.9527 2.3633 0.9106 0.959 4.5349 0.9094
#> biased by +20 20.0000 20.0000 -3.1009 1.000 -20.0000 1.0000
#> wrong scale 51.3557 50.4164 -26.0394 1.000 -10000.0000 100.0000
#> mean predictor 9.8762 7.8065 0.0000 NA 50.0845 NA
#> CCC
#> good model 0.9577
#> biased by +20 0.3283
#> wrong scale 0.0007
#> mean predictor 0.0000
Row 3 is the counterexample: correlation 1.0, RMSE 50, \(R^2 = -24\). Row 4 shows what a negative \(R^2\) is measured against, the constant mean predictor scores exactly 0, so anything below that is worse than predicting the average.
Optimism (§9.2) has a closed form only for linear models with known noise. Resampling estimates it for anything.
Split once, fit on one part, score on the other. Simple, unbiased for the smaller training size, and highly variable.
set.seed(71)
n_hv <- 200; p_hv <- 6
beta_hv <- c(1.5, -1, 0.8, rep(0, p_hv - 3))
X_hv <- matrix(rnorm(n_hv * p_hv), n_hv, p_hv)
y_hv <- as.vector(X_hv %*% beta_hv) + rnorm(n_hv, sd = 1.5)
d_hv <- data.frame(X_hv, y = y_hv)
holdout_rmse <- function(prop = 0.7) {
tr <- sample(n_hv, floor(prop * n_hv))
fit <- lm(y ~ ., data = d_hv[tr, ])
sqrt(mean((predict(fit, d_hv[-tr, ]) - d_hv$y[-tr])^2))
}
set.seed(73)
hv <- replicate(2000, holdout_rmse())
c(mean = round(mean(hv), 4), sd = round(sd(hv), 4),
q025 = round(quantile(hv, 0.025), 4), q975 = round(quantile(hv, 0.975), 4),
relative_spread = round(sd(hv) / mean(hv), 4))#> mean sd q025.2.5% q975.97.5% relative_spread
#> 1.6139 0.1511 1.3158 1.9063 0.0937
ggplot(data.frame(rmse = hv), aes(rmse)) +
geom_histogram(bins = 45, fill = "steelblue", color = "white") +
geom_vline(xintercept = mean(hv), color = "firebrick", linewidth = 1) +
labs(title = "2,000 different 70/30 splits of the same 200 observations",
subtitle = sprintf("Same data, same model. The 95%% range spans %.2f to %.2f",
quantile(hv, 0.025), quantile(hv, 0.975)),
x = "Held-out RMSE", y = "Splits") +
theme_dspa()Common misconception: “I held out a test set, so my estimate is reliable.” It is unbiased but not precise. A single 70/30 split of 200 observations here gives a held-out RMSE anywhere in a range spanning roughly 30% of its own value. Reporting one number from one split conveys none of that uncertainty. Resampling averages over many splits, which is what makes the estimate usable.
Partition into \(k\) folds; for each, train on \(k-1\) and assess on the held-out one; average.
\[\widehat{\mathrm{CV}}_{(k)}=\frac{1}{n}\sum_{i=1}^{n}L\Big(y_i,\ \hat f^{-\kappa(i)}(x_i)\Big),\]
where \(\kappa(i)\) is the fold containing \(i\) and \(\hat f^{-\kappa}\) is the model fitted with fold \(\kappa\) removed. Every observation is assessed exactly once, by a model that never saw it.
Bias. Each model trains on \(n(1-1/k)\) observations rather than \(n\). Since learning curves decrease in \(n\), the CV estimate is pessimistically biased, and the bias grows as \(k\) shrinks. At \(k=2\) each model sees half the data.
Variance. The \(k\) fold estimates are positively correlated, the training sets share \(n(1-2/k)\) observations. Larger \(k\) means more overlap, more correlation, and (by the ensemble variance identity of Chapter 5, §5.22.1) a variance floor that averaging cannot reduce. LOOCV has the most overlap of all.
set.seed(81)
cv_rmse <- function(dat, k, seed) {
set.seed(seed)
fold <- sample(rep(seq_len(k), length.out = nrow(dat)))
err <- vapply(seq_len(k), function(f) {
fit <- lm(y ~ ., data = dat[fold != f, ])
mean((predict(fit, dat[fold == f, ]) - dat$y[fold == f])^2)
}, numeric(1))
sqrt(mean(err))
}
# Truth: RMSE of a model fitted on ALL n, evaluated on a very large fresh sample
truth_rmse <- local({
fit <- lm(y ~ ., data = d_hv)
Xb <- matrix(rnorm(50000 * p_hv), 50000, p_hv)
yb <- as.vector(Xb %*% beta_hv) + rnorm(50000, sd = 1.5)
sqrt(mean((yb - cbind(1, Xb) %*% coef(fit))^2))
})
ks <- c(2, 3, 5, 10, 20, 50, nrow(d_hv))
bv_tab <- do.call(rbind, lapply(ks, function(k) {
est <- vapply(1:150, \(r) cv_rmse(d_hv, k, seed = 1000 + r), numeric(1))
data.frame(k = k, mean = mean(est), sd = sd(est),
bias = mean(est) - truth_rmse)
}))
bv_tab$k_label <- ifelse(bv_tab$k == nrow(d_hv), "LOOCV", as.character(bv_tab$k))
bv_tab |> mutate(across(c(mean, sd, bias), \(z) round(z, 4)))#> k mean sd bias k_label
#> 1 2 1.6343 0.0450 0.1218 2
#> 2 3 1.6168 0.0283 0.1043 3
#> 3 5 1.6116 0.0186 0.0991 5
#> 4 10 1.6079 0.0123 0.0953 10
#> 5 20 1.6064 0.0076 0.0939 20
#> 6 50 1.6049 0.0040 0.0924 50
#> 7 200 1.6048 0.0000 0.0922 LOOCV
#> truth
#> 1.5125
bv_tab |>
select(k, Bias = bias, `Standard deviation` = sd) |>
pivot_longer(-k, names_to = "component", values_to = "value") |>
ggplot(aes(k, value, color = component)) +
geom_hline(yintercept = 0, color = "grey65") +
geom_line(linewidth = 1) + geom_point(size = 2.2) +
scale_x_log10(breaks = ks, labels = bv_tab$k_label) +
scale_color_manual(values = c(Bias = "#D8433B",
`Standard deviation` = "#3B7DD8")) +
labs(title = "Bias and variance of the cross-validation estimator itself",
subtitle = "Small k trains on less data (pessimistic bias); large k gives overlapping training sets (variance floor)",
x = "Number of folds k (log scale)", y = NULL, color = NULL) +
theme_dspa()Bias falls monotonically toward zero as \(k\) grows; the standard deviation does not fall correspondingly, because the fold estimates become increasingly correlated. \(k=5\) or \(k=10\) is the standard compromise for exactly this reason.
The tradeoff is a surface over \((k,n)\):
n_grid2 <- c(40, 60, 100, 160, 260, 400)
k_grid2 <- c(2, 3, 5, 10, 20)
cv_bias_at <- function(n, k, reps = 40) {
set.seed(n * 100 + k)
vapply(seq_len(reps), function(r) {
X <- matrix(rnorm(n * p_hv), n, p_hv)
y <- as.vector(X %*% beta_hv) + rnorm(n, sd = 1.5)
dd <- data.frame(X, y = y)
fold <- sample(rep(seq_len(k), length.out = n))
e <- vapply(seq_len(k), function(f) {
fit <- lm(y ~ ., data = dd[fold != f, ])
mean((predict(fit, dd[fold == f, ]) - dd$y[fold == f])^2)
}, numeric(1))
sqrt(mean(e))
}, numeric(1)) |> mean()
}
# Zcv <- outer(k_grid2, n_grid2, Vectorize(cv_bias_at)) - truth_rmse
Zcv <-
outer(n_grid2, k_grid2, Vectorize(function(n, k) cv_bias_at(n = n, k = k))) -
truth_rmse
plot_ly(x = n_grid2, y = k_grid2, z = Zcv, type = "surface",
colorscale = "Viridis", colorbar = list(title = "CV bias")) |>
layout(title = "Bias of the k-fold CV estimate, as a function of k and n",
scene = list(xaxis = list(title = "Sample size n"),
yaxis = list(title = "Folds k"),
zaxis = list(title = "Estimated - true RMSE")))The bias is largest in the small-\(n\), small-\(k\) corner and flattens as either grows. With \(n\) in the thousands the choice of \(k\) scarcely matters; with \(n\) in the dozens it matters a great deal.
Stratification preserves the class proportions in every fold. For classification, especially under imbalance, it is not optional: an unstratified fold can contain almost no minority cases, making its metric undefined or wildly variable.
set.seed(83)
y_imb <- factor(rep(c("pos", "neg"), times = c(25, 475)))
strat_check <- function(strata = TRUE) {
f <- if (strata) rsample::vfold_cv(data.frame(y = y_imb), v = 10, strata = y)
else rsample::vfold_cv(data.frame(y = y_imb), v = 10)
vapply(f$splits, \(s) sum(rsample::assessment(s)$y == "pos"), numeric(1))
}
rbind(unstratified = range(strat_check(FALSE)),
stratified = range(strat_check(TRUE))) |>
`colnames<-`(c("min positives per fold", "max positives per fold"))#> min positives per fold max positives per fold
#> unstratified 0 4
#> stratified 1 4
LOOCV sets \(k=n\): each observation is held out once. It is nearly unbiased for \(\mathrm{Err}\) but costs \(n\) model fits, except that for linear smoothers it costs one.
The LOOCV shortcut. If \(\hat{\mathbf y}=S\mathbf y\) for a smoother matrix \(S\) not depending on \(\mathbf y\), then \[\boxed{\;\mathrm{CV}_{(n)}=\frac{1}{n}\sum_{i=1}^{n}\left(\frac{y_i-\hat y_i}{1-h_{ii}}\right)^{2}\;}\] where \(h_{ii}=S_{ii}\) is the \(i\)-th leverage. One fit replaces \(n\).
Why. Removing observation \(i\) changes the fit by an amount that can be written in closed form via the Sherman–Morrison update; the leave-one-out residual turns out to be the ordinary residual inflated by \(1/(1-h_{ii})\).
Generalized cross-validation replaces each \(h_{ii}\) by their average \(\operatorname{tr}(S)/n\):
\[\mathrm{GCV}=\frac{1}{n}\sum_{i=1}^{n}\left(\frac{y_i-\hat y_i}{1-\operatorname{tr}(S)/n}\right)^{2},\]
which is rotation-invariant, cheaper still, and the standard criterion for smoothing-parameter selection.
set.seed(85)
n_lo <- 120
X_lo <- matrix(rnorm(n_lo * 5), n_lo, 5)
y_lo <- as.vector(X_lo %*% c(1, -1, 0.5, 0, 0)) + rnorm(n_lo)
d_lo <- data.frame(X_lo, y = y_lo)
# Brute force: n model fits
t_brute <- system.time({
loo_brute <- mean(vapply(seq_len(n_lo), function(i) {
fit <- lm(y ~ ., data = d_lo[-i, ])
(d_lo$y[i] - predict(fit, d_lo[i, ]))^2
}, numeric(1)))
})[["elapsed"]]
# Shortcut: ONE model fit
t_fast <- system.time({
fit_all <- lm(y ~ ., data = d_lo)
h <- hatvalues(fit_all)
loo_fast <- mean((residuals(fit_all) / (1 - h))^2)
gcv <- mean((residuals(fit_all) / (1 - sum(h) / n_lo))^2)
})[["elapsed"]]
c(loocv_brute_force = round(loo_brute, 6),
loocv_closed_form = round(loo_fast, 6),
difference = signif(abs(loo_brute - loo_fast), 3),
GCV = round(gcv, 6),
brute_seconds = round(t_brute, 3), shortcut_seconds = round(t_fast, 4),
fits_required = paste(n_lo, "vs 1"))#> loocv_brute_force loocv_closed_form difference GCV
#> "1.081128" "1.081128" "2.22e-16" "1.083113"
#> brute_seconds shortcut_seconds fits_required
#> "0.09" "0" "120 vs 1"
Identical to six decimals, from one fit instead of 120. This is why LOOCV is routine for ridge regression, smoothing splines, and kernel smoothers, and impractical for random forests and neural networks, the shortcut exists only when the fit is linear in \(\mathbf y\).
A single \(k\)-fold partition is one random assignment. Repeating with different partitions and averaging reduces the variance due to the partition, though not the variance due to the data.
set.seed(87)
rep_cv <- function(reps) {
mean(vapply(seq_len(reps), \(r) cv_rmse(d_hv, 10, seed = 5000 + r), numeric(1)))
}
rc <- data.frame(repeats = c(1, 2, 3, 5, 10, 20))
rc$sd_of_estimate <- vapply(rc$repeats, function(R) {
sd(vapply(1:60, function(b) {
mean(vapply(seq_len(R), \(r) cv_rmse(d_hv, 10, seed = b * 1000 + r), numeric(1)))
}, numeric(1)))
}, numeric(1))
rc |> mutate(sd_of_estimate = round(sd_of_estimate, 5))#> repeats sd_of_estimate
#> 1 1 0.01123
#> 2 2 0.00825
#> 3 3 0.00733
#> 4 5 0.00602
#> 5 10 0.00349
#> 6 20 0.00226
ggplot(rc, aes(repeats, sd_of_estimate)) +
geom_line(linewidth = 1, color = "steelblue") + geom_point(size = 2.4) +
labs(title = "Repeating 10-fold CV reduces partition variance",
subtitle = "It cannot reduce the variance arising from the single dataset itself",
x = "Number of repeats", y = "SD of the CV estimate") +
theme_dspa()The curve flattens: repetition removes the partition noise and then stops helping, because the remaining variability comes from having only one sample of \(n\) observations.
No unbiased estimator of the variance of \(k\)-fold CV exists. The fold errors are correlated in a way that depends on the unknown data distribution (Bengio & Grandvalet, 2004). This is not a gap in the literature, it is a proof of impossibility, and it is why comparing models on CV scores needs the corrected test of §9.19.
Resample \(n\) observations with replacement, fit, and predict the omitted ones.
Each bootstrap sample omits a fraction
\[\left(1-\frac1n\right)^{n}\ \xrightarrow[n\to\infty]{}\ e^{-1}\approx0.368,\]
so about 63.2% of observations appear at least once, giving the estimator its name.
set.seed(91)
prop_unique <- function(n, B = 4000)
mean(replicate(B, length(unique(sample(n, n, replace = TRUE))) / n))
data.frame(n = c(10, 50, 200, 1000),
observed_unique = round(vapply(c(10, 50, 200, 1000), prop_unique, numeric(1)), 4),
limit = round(1 - exp(-1), 4))#> n observed_unique limit
#> 1 10 0.6518 0.6321
#> 2 50 0.6346 0.6321
#> 3 200 0.6326 0.6321
#> 4 1000 0.6323 0.6321
The out-of-bag error \(\widehat{\mathrm{Err}}^{(1)}\) is pessimistic, because each model effectively trains on only 63.2% of the data. The apparent error \(\overline{\mathrm{err}}\) is optimistic. The .632 estimator blends them:
\[\boxed{\;\widehat{\mathrm{Err}}^{.632}=0.368\,\overline{\mathrm{err}}+0.632\,\widehat{\mathrm{Err}}^{(1)}\;}\]
The first term is the apparent error, the model’s error on the data it was fitted to. Substituting a held-out error there defeats the whole construction, because both terms then describe the same out-of-sample quantity and there is nothing optimistic left to balance against.
The out-of-bag set must also be built as
setdiff(seq_len(n), boot_indices). Indexing only into1:length(boot_indices)silently excludes the tail of the dataset from ever being out-of-bag.
For learners that overfit badly, .632 is itself optimistic. The .632+ estimator (Efron & Tibshirani, 1997) corrects it using the no-information error rate \(\hat\gamma\), the error when predictions and responses are independent:
\[\hat R=\frac{\widehat{\mathrm{Err}}^{(1)}-\overline{\mathrm{err}}}{\hat\gamma-\overline{\mathrm{err}}}, \qquad \hat w=\frac{0.632}{1-0.368\hat R}, \qquad \widehat{\mathrm{Err}}^{.632+}=(1-\hat w)\,\overline{\mathrm{err}}+\hat w\,\widehat{\mathrm{Err}}^{(1)} .\]
When the learner does not overfit, \(\hat R\approx0\) and \(\hat w\approx0.632\), recovering .632. When it interpolates (\(\overline{\mathrm{err}}=0\)), \(\hat w\to1\) and the estimator becomes the pure out-of-bag error.
boot_632 <- function(dat, formula, fitter, predictor, loss, B = 100, seed = 1) {
set.seed(seed); n <- nrow(dat)
# Apparent error: the model fitted on ALL the data, scored on ALL the data
full_fit <- fitter(formula, dat)
err_bar <- loss(dat, predictor(full_fit, dat))
# Leave-one-out bootstrap: for each observation, average the loss over the
# bootstrap replicates in which it was OUT of bag.
loss_i <- matrix(NA_real_, n, B)
for (b in seq_len(B)) {
idx <- sample(n, n, replace = TRUE)
oob <- setdiff(seq_len(n), idx) # the CORRECT out-of-bag set
if (!length(oob)) next
fb <- fitter(formula, dat[idx, , drop = FALSE])
loss_i[oob, b] <- loss(dat[oob, , drop = FALSE],
predictor(fb, dat[oob, , drop = FALSE]), per_case = TRUE)
}
err_oob <- mean(rowMeans(loss_i, na.rm = TRUE), na.rm = TRUE)
# No-information rate: predictions paired with SHUFFLED responses
yhat_full <- predictor(full_fit, dat)
gamma <- mean(replicate(50, {
d2 <- dat; d2[[all.vars(formula)[1]]] <- sample(d2[[all.vars(formula)[1]]])
loss(d2, yhat_full)
}))
R <- if (gamma > err_bar) (err_oob - err_bar) / (gamma - err_bar) else 0
R <- min(max(R, 0), 1)
w <- 0.632 / (1 - 0.368 * R)
c(apparent = err_bar, oob = err_oob, no_information = gamma,
R = R, weight = w,
err_632 = 0.368 * err_bar + 0.632 * err_oob,
err_632p = (1 - w) * err_bar + w * err_oob)
}library(rpart)
loss_01 <- function(dat, pred, per_case = FALSE) {
z <- as.character(pred) != as.character(dat$y)
if (per_case) as.numeric(z) else mean(z)
}
set.seed(93)
n_bs <- 300
d_bs <- data.frame(matrix(rnorm(n_bs * 6), n_bs, 6))
d_bs$y <- factor(ifelse(d_bs$X1 + d_bs$X2 + rnorm(n_bs) > 0, "a", "b"))
# A heavily overfitting learner (unpruned tree) and a stable one (logistic)
fit_tree <- function(f, d) rpart(f, d, control = rpart.control(cp = 0, minsplit = 2))
pred_tree <- function(m, d) predict(m, d, type = "class")
fit_glm <- function(f, d) glm(f, d, family = binomial())
pred_glm <- function(m, d) factor(ifelse(predict(m, d, type = "response") > 0.5,
"b", "a"), levels = c("a", "b"))
rbind(`unpruned tree` = boot_632(d_bs, y ~ ., fit_tree, pred_tree, loss_01, B = 80),
`logistic` = boot_632(d_bs, y ~ ., fit_glm, pred_glm, loss_01, B = 80)) |>
round(4)#> apparent oob no_information R weight err_632 err_632p
#> unpruned tree 0.0000 0.2933 0.4919 0.5963 0.8097 0.1854 0.2375
#> logistic 0.2033 0.2126 0.4945 0.0319 0.6395 0.2092 0.2093
Read the first row. The unpruned tree has apparent error 0, it interpolates its training data, so \(\hat R\) is large, \(\hat w\) moves toward 1, and .632+ lands well above .632. For the logistic model, which does not overfit, the two estimators nearly coincide. That divergence is exactly the correction .632+ was built to supply.
Common misconception: “I cross-validated while tuning, so my CV score is an honest estimate.” It is not. Selecting the hyperparameter that maximizes a CV score and then reporting that maximum is selection on the same data, the maximum of a set of noisy estimates is biased upward. The more configurations you search, the larger the bias.
Nested CV separates the two jobs: an inner loop selects hyperparameters within each training set, and an outer loop scores the whole selection-plus-fitting procedure on data neither loop has seen.
set.seed(101)
n_nc <- 250
X_nc <- matrix(rnorm(n_nc * 30), n_nc, 30)
y_nc <- factor(ifelse(X_nc[, 1] + X_nc[, 2] + rnorm(n_nc, sd = 1.2) > 0, "a", "b"))
d_nc <- data.frame(X_nc, y = y_nc)
k_grid <- c(1, 3, 5, 9, 15, 25, 41, 61)
knn_acc <- function(tr, te, k) {
p <- class::knn(tr[, -ncol(tr)], te[, -ncol(te)], tr$y, k = k)
mean(p == te$y)
}
# FLAT: tune and report on the same folds
flat_cv <- function(seed) {
set.seed(seed)
f <- sample(rep(1:5, length.out = n_nc))
acc <- vapply(k_grid, function(k)
mean(vapply(1:5, \(i) knn_acc(d_nc[f != i, ], d_nc[f == i, ], k), numeric(1))),
numeric(1))
max(acc) # the reported number
}
# NESTED: inner loop tunes, outer loop scores untouched data
nested_cv <- function(seed) {
set.seed(seed)
outer_f <- sample(rep(1:5, length.out = n_nc))
mean(vapply(1:5, function(i) {
tr <- d_nc[outer_f != i, ]; te <- d_nc[outer_f == i, ]
inner_f <- sample(rep(1:5, length.out = nrow(tr)))
inner <- vapply(k_grid, function(k)
mean(vapply(1:5, \(j) knn_acc(tr[inner_f != j, ], tr[inner_f == j, ], k),
numeric(1))), numeric(1))
knn_acc(tr, te, k_grid[which.max(inner)])
}, numeric(1)))
}
set.seed(103)
flat <- vapply(1:20, flat_cv, numeric(1))
nested <- vapply(1:20, nested_cv, numeric(1))
c(flat_CV_reported = round(mean(flat), 4),
nested_CV_estimate = round(mean(nested), 4),
optimism = round(mean(flat) - mean(nested), 4))#> flat_CV_reported nested_CV_estimate optimism
#> 0.674 0.650 0.024
The flat estimate is optimistic by a measurable margin. The gap grows with the size of the search, a point worth seeing as a surface:
grid_sizes <- c(2, 4, 8, 16)
n_sizes <- c(80, 150, 250, 400)
optimism_at <- function(n, g, reps = 6) {
set.seed(n + g)
kg <- round(seq(1, 61, length.out = g))
X <- matrix(rnorm(n * 30), n, 30)
y <- factor(ifelse(X[, 1] + X[, 2] + rnorm(n, sd = 1.2) > 0, "a", "b"))
d <- data.frame(X, y = y)
vapply(seq_len(reps), function(r) {
f <- sample(rep(1:5, length.out = n))
acc <- vapply(kg, function(k)
mean(vapply(1:5, function(i) {
p <- class::knn(d[f != i, -ncol(d)], d[f == i, -ncol(d)], d$y[f != i], k = k)
mean(p == d$y[f == i])
}, numeric(1))), numeric(1))
max(acc) - mean(acc) # optimism from taking the max
}, numeric(1)) |> mean()
}
Zno <- outer(grid_sizes, n_sizes, Vectorize(optimism_at))
plot_ly(x = n_sizes, y = grid_sizes, z = Zno, type = "surface",
colorscale = "Inferno", reversescale = TRUE,
colorbar = list(title = "Selection\noptimism")) |>
layout(title = "Optimism from reporting the best of several CV scores",
scene = list(xaxis = list(title = "Sample size n"),
yaxis = list(title = "Hyperparameter grid size"),
zaxis = list(title = "max(CV) - mean(CV)")))The surface rises with the grid size and falls with \(n\). Searching a large space on a small dataset is precisely where flat CV misleads most.
\(k\)-fold CV assumes observations are exchangeable. Two common structures break that assumption, and each has its own remedy.
Repeated measures on the same subject, multiple samples from one site, or multiple lesions in one patient are not independent. Random folds place a subject’s observations on both sides of the split, so the model can memorize the subject rather than learn the signal.
set.seed(111)
n_sub <- 40; per_sub <- 5
subj <- rep(seq_len(n_sub), each = per_sub)
sub_effect <- rnorm(n_sub, sd = 2) # strong subject effect
y_g <- factor(ifelse(sub_effect[subj] + rnorm(n_sub * per_sub, sd = 0.5) > 0,
"a", "b"))
Xg2 <- cbind(sub_effect[subj] + rnorm(n_sub * per_sub, sd = 0.5),
matrix(rnorm(n_sub * per_sub * 3), ncol = 3))
d_g <- data.frame(Xg2, subject = subj, y = y_g)
acc_folds <- function(folds) {
mean(vapply(folds, function(idx_te) {
tr <- d_g[-idx_te, ]; te <- d_g[idx_te, ]
p <- class::knn(tr[, 1:4], te[, 1:4], tr$y, k = 5)
mean(p == te$y)
}, numeric(1)))
}
set.seed(113)
f_rand <- split(sample(nrow(d_g)), rep(1:5, length.out = nrow(d_g)))
grp <- split(seq_len(n_sub), rep(1:5, length.out = n_sub))
f_grp <- lapply(grp, \(s) which(d_g$subject %in% s))
c(random_folds = round(acc_folds(f_rand), 4),
grouped_folds = round(acc_folds(f_grp), 4),
optimism = round(acc_folds(f_rand) - acc_folds(f_grp), 4))#> random_folds grouped_folds optimism
#> 0.835 0.825 0.010
Random folds report substantially higher accuracy on data where the
true signal lives entirely at the subject level. Split by
group, using rsample::group_vfold_cv().
Randomly splitting a time series puts day \(t\) in training and day \(t+1\) in assessment. On autocorrelated data those are nearly duplicates, so the estimate measures interpolation rather than forecasting. The remedy is forward chaining: train on \([1,\tau]\), assess on \((\tau,\tau+h]\), and roll \(\tau\) forward.
set.seed(121)
T_len <- 400
ar <- as.numeric(arima.sim(list(ar = 0.95), T_len))
noise_pred <- as.numeric(arima.sim(list(ar = 0.95), T_len)) # UNRELATED to ar
d_t <- data.frame(x = noise_pred, y = ar)
# Random 5-fold
set.seed(123)
f_t <- sample(rep(1:5, length.out = T_len))
r2_random <- mean(vapply(1:5, function(i) {
fit <- lm(y ~ x, d_t[f_t != i, ]); te <- d_t[f_t == i, ]
1 - sum((predict(fit, te) - te$y)^2) / sum((te$y - mean(te$y))^2)
}, numeric(1)))
# Rolling origin
origins <- round(seq(0.5 * T_len, T_len - 40, length.out = 5))
r2_rolling <- mean(vapply(origins, function(tau) {
fit <- lm(y ~ x, d_t[1:tau, ]); te <- d_t[(tau + 1):(tau + 40), ]
1 - sum((predict(fit, te) - te$y)^2) / sum((te$y - mean(te$y))^2)
}, numeric(1)))
c(random_folds_R2 = round(r2_random, 4),
rolling_origin_R2 = round(r2_rolling, 4),
truth = "0 -- x and y are independent AR(1) series")#> random_folds_R2
#> "-0.0118"
#> rolling_origin_R2
#> "-0.2156"
#> truth
#> "0 -- x and y are independent AR(1) series"
There is no relationship between x and
y, both are independent AR(1) processes. Random folds
report a positive \(R^2\); rolling
origin does not.
ggplot(data.frame(t = 1:T_len, y = ar), aes(t, y)) +
geom_line(linewidth = 0.4, color = "grey40") +
annotate("rect", xmin = origins[3] + 1, xmax = origins[3] + 40,
ymin = -Inf, ymax = Inf, alpha = 0.2, fill = "#D8433B") +
annotate("rect", xmin = 1, xmax = origins[3],
ymin = -Inf, ymax = Inf, alpha = 0.12, fill = "#3B7DD8") +
labs(title = "Rolling-origin evaluation of a time series",
subtitle = "Blue: training window. Red: the forecast horizon that follows it. Nothing is shuffled",
x = "Time", y = "y") +
theme_dspa()Let \(C(m)\) be the cost of one model fit on \(m\) observations.
| Scheme | Fits | Cost | Note |
|---|---|---|---|
| Holdout | \(1\) | \(C(0.7n)\) | Cheapest; highest variance |
| \(k\)-fold CV | \(k\) | \(k\,C\!\left(n\tfrac{k-1}{k}\right)\) | \(k=5\) or \(10\) standard |
| Repeated \(k\)-fold | \(rk\) | \(rk\,C(\cdot)\) | Reduces partition variance only |
| LOOCV (general) | \(n\) | \(n\,C(n-1)\) | Impractical for most learners |
| LOOCV (linear smoother) | \(\mathbf{1}\) | \(C(n)+O(n)\) | Hat-value shortcut |
| GCV | \(1\) | \(C(n)+O(1)\) | Uses \(\operatorname{tr}(S)/n\) |
| Bootstrap OOB / .632 | \(B\) | \(B\,C(n)\) | \(B=50\)–\(200\) typical |
| Nested CV | \(k_{\text{out}}\!\left(1+k_{\text{in}}|\Lambda|\right)\) | — | The honest cost of honest tuning |
| Grouped \(k\)-fold | \(k\) | as \(k\)-fold | Folds sized by group, not row |
| Rolling origin | \(\#\)origins | \(\sum_\tau C(\tau)\) | Training sets grow |
cost_table <- function(k_out = 5, k_in = 5, grid = 10, repeats = 3, n = 1000) {
data.frame(
scheme = c("Holdout", "5-fold", "10-fold", "3x10-fold repeated",
"LOOCV (general)", "LOOCV (linear smoother)",
"Bootstrap B=100", "Nested 5x5, grid 10"),
model_fits = c(1, 5, 10, repeats * 10, n, 1, 100,
k_out * (1 + k_in * grid)))
}
cost_table()#> scheme model_fits
#> 1 Holdout 1
#> 2 5-fold 5
#> 3 10-fold 10
#> 4 3x10-fold repeated 30
#> 5 LOOCV (general) 1000
#> 6 LOOCV (linear smoother) 1
#> 7 Bootstrap B=100 100
#> 8 Nested 5x5, grid 10 255
Nested CV with a modest grid costs 255 fits where flat CV costs 5. That factor is the price of an unbiased estimate, and it is why the honest protocol is often reserved for the final reported number while flat CV guides exploration.
Two models produce CV scores differing by two percentage points. Is that a difference?
qol <- dspa_read("https://umich.instructure.com/files/481332/download?download_frd=1",
"Case06_QoL_Symptom_ChronicIllness.csv")
qol <- qol |> filter(CHRONICDISEASESCORE != -9, CHARLSONSCORE != -9)
# The threshold is COMPUTED from the data. A median split gives balanced
# classes, so accuracy is interpretable against a ~50% no-information rate.
cut_cds <- median(qol$CHRONICDISEASESCORE)
qol$cd <- factor(qol$CHRONICDISEASESCORE > cut_cds,
levels = c(FALSE, TRUE),
labels = c("minor_disease", "severe_disease"))
c(cut_point = round(cut_cds, 4))#> cut_point
#> 1.39
#>
#> minor_disease severe_disease
#> 0.5009 0.4991
# The feature set is defined ONCE, by name, and reused by every model.
# CHRONICDISEASESCORE determines `cd` exactly -- leaving it in would make
# every accuracy 1. ID and INTERVIEWDATE are administrative.
drop_cols <- c("ID", "CHRONICDISEASESCORE", "INTERVIEWDATE")
qol_model_df <- qol |> select(-any_of(drop_cols))
c(dropped = paste(intersect(drop_cols, names(qol)), collapse = ", "),
predictors = ncol(qol_model_df) - 1,
leakage_check_correlation_with_outcome = "CHRONICDISEASESCORE excluded")#> dropped
#> "ID, CHRONICDISEASESCORE, INTERVIEWDATE"
#> predictors
#> "38"
#> leakage_check_correlation_with_outcome
#> "CHRONICDISEASESCORE excluded"
set.seed(1234)
sp_qol <- rsample::initial_split(qol_model_df, prop = 0.8, strata = cd)
qol_train <- rsample::training(sp_qol); qol_test <- rsample::testing(sp_qol)
NIR_qol <- max(prop.table(table(qol_test$cd)))
c(train = nrow(qol_train), test = nrow(qol_test),
no_information_rate = round(NIR_qol, 4))#> train test no_information_rate
#> 1751.0000 439.0000 0.5011
# library(doParallel)
# n_cores <- max(1, parallel::detectCores() - 1)
# cl <- makePSOCKcluster(n_cores)
# registerDoParallel(cl)
# on.exit({ stopCluster(cl); registerDoSEQ() }, add = TRUE) # never orphan workers
#
# set.seed(1234)
# ctrl <- trainControl(method = "repeatedcv", number = 10, repeats = 3,
# classProbs = TRUE, summaryFunction = twoClassSummary,
# savePredictions = "final")
#
# fits <- list(
# RF = train(cd ~ ., data = qol_train, method = "ranger", metric = "ROC",
# trControl = ctrl, tuneLength = 3, num.trees = 300, num.threads = 1),
# kNN = train(cd ~ ., data = qol_train, method = "knn", metric = "ROC",
# trControl = ctrl, tuneLength = 6,
# preProcess = c("center", "scale")),
# SVM = train(cd ~ ., data = qol_train, method = "svmRadial", metric = "ROC",
# trControl = ctrl, tuneLength = 3,
# preProcess = c("center", "scale")),
# GBM = train(cd ~ ., data = qol_train, method = "gbm", metric = "ROC",
# trControl = ctrl, tuneLength = 3, verbose = FALSE),
# GLM = train(cd ~ ., data = qol_train, method = "glm", family = "binomial",
# metric = "ROC", trControl = ctrl))
# stopCluster(cl); registerDoSEQ()
library(doParallel)
n_cores <- max(1, parallel::detectCores() - 1)
cl <- makePSOCKcluster(n_cores)
registerDoParallel(cl)
set.seed(1234)
ctrl <- trainControl(method = "repeatedcv", number = 10, repeats = 3,
classProbs = TRUE, summaryFunction = twoClassSummary,
savePredictions = "final")
fits <- list(
RF = train(cd ~ ., data = qol_train, method = "ranger", metric = "ROC",
trControl = ctrl, tuneLength = 3, num.trees = 300, num.threads = 1),
kNN = train(cd ~ ., data = qol_train, method = "knn", metric = "ROC",
trControl = ctrl, tuneLength = 6, preProcess = c("center","scale")),
SVM = train(cd ~ ., data = qol_train, method = "svmRadial", metric = "ROC",
trControl = ctrl, tuneLength = 3, preProcess = c("center","scale")),
GBM = train(cd ~ ., data = qol_train, method = "gbm", metric = "ROC",
trControl = ctrl, tuneLength = 3, verbose = FALSE),
GLM = train(cd ~ ., data = qol_train, method = "glm", family = "binomial",
metric = "ROC", trControl = ctrl)
)
# Robust cleanup — workers may already be gone
tryCatch(stopCluster(cl), error = function(e) NULL)
tryCatch(registerDoSEQ(), error = function(e) NULL)The preProcess argument does real work:
caret recomputes the centering and scaling inside
every resample, using only that resample’s analysis set.
Standardizing the whole frame beforehand would leak (Chapter 5, §5.4.1).
#> Min. 1st Qu. Median Mean 3rd Qu. Max. NA's
#> RF 0.6257 0.6604 0.6799 0.6837 0.7019 0.7717 0
#> kNN 0.5114 0.5282 0.5588 0.5601 0.5850 0.6342 0
#> SVM 0.5270 0.6139 0.6274 0.6322 0.6604 0.7159 0
#> GBM 0.6123 0.6676 0.6925 0.6935 0.7236 0.7729 0
#> GLM 0.5272 0.6128 0.6232 0.6265 0.6509 0.6981 0
res_long <- res$values |>
select(Resample, ends_with("~ROC")) |>
pivot_longer(-Resample, names_to = "model", values_to = "ROC") |>
mutate(model = sub("~ROC$", "", model))
ggplot(res_long, aes(reorder(model, ROC, median), ROC)) +
geom_boxplot(fill = "#9EC5E8", width = 0.55, outlier.alpha = 0.4) +
geom_jitter(width = 0.08, alpha = 0.25, size = 0.8) +
coord_flip() +
labs(title = "Cross-validated AUC across 30 common resamples",
subtitle = "Identical folds for every model, so the comparison is paired",
x = NULL, y = "AUC") +
theme_dspa()# --- Interactive equivalents, plus caret's lattice displays ----------------
plot_ly(res_long, x = ~model, y = ~ROC, color = ~model, type = "box") |>
layout(title = "Cross-validated AUC by model", showlegend = FALSE)
dens <- with(res_long, tapply(ROC, INDEX = model, density))
df_d <- data.frame(
x = unlist(lapply(dens, "[[", "x")),
y = unlist(lapply(dens, "[[", "y")),
model = rep(names(dens), each = length(dens[[1]]$x)))
plot_ly(df_d, x = ~x, y = ~y, color = ~model) |> add_lines() |>
layout(title = "Performance density plots (AUC)",
xaxis = list(title = "AUC"), yaxis = list(title = "Density"),
legend = list(orientation = "h"))
res_wide <- res_long |> pivot_wider(names_from = model, values_from = ROC)
dims <- purrr::map2(select(res_wide, -Resample),
names(select(res_wide, -Resample)),
~list(values = .x, label = .y))
plot_ly(type = "splom", dimensions = setNames(dims, NULL),
showupperhalf = FALSE, diagonal = list(visible = FALSE)) |>
layout(title = "Pairwise resample performance (AUC)")wide <- res_long |> pivot_wider(names_from = model, values_from = ROC)
model_names <- setdiff(names(wide), "Resample")
pairs_df <- do.call(rbind, combn(model_names, 2, simplify = FALSE, FUN = function(pr) {
d <- wide[[pr[1]]] - wide[[pr[2]]]
naive <- t.test(d)
corr <- corrected_t_test(d, k = 10)
data.frame(comparison = paste(pr, collapse = " - "),
mean_diff = round(mean(d), 4),
naive_p = signif(naive$p.value, 3),
corrected_p = signif(corr[["p_value"]], 3))
}))
pairs_df$corrected_q <- signif(p.adjust(pairs_df$corrected_p, "holm"), 3)
pairs_df#> comparison mean_diff naive_p corrected_p corrected_q
#> 1 RF - kNN 0.1237 1.31e-14 1.67e-07 1.50e-06
#> 2 RF - SVM 0.0515 4.38e-05 2.84e-02 8.52e-02
#> 3 RF - GBM -0.0098 3.21e-01 6.31e-01 1.00e+00
#> 4 RF - GLM 0.0572 5.69e-07 4.69e-03 2.34e-02
#> 5 kNN - SVM -0.0722 8.92e-08 2.00e-03 1.40e-02
#> 6 kNN - GBM -0.1335 1.43e-15 3.31e-08 3.31e-07
#> 7 kNN - GLM -0.0664 3.56e-07 3.80e-03 2.28e-02
#> 8 SVM - GBM -0.0613 4.88e-06 1.18e-02 4.72e-02
#> 9 SVM - GLM 0.0057 5.93e-01 7.97e-01 1.00e+00
#> 10 GBM - GLM 0.0670 5.16e-08 1.54e-03 1.23e-02
Three corrections stack here, and each matters. The corrected \(t\) widens the interval for fold overlap. Holm’s adjustment accounts for testing all \(\binom{5}{2}=10\) pairs. And the naive \(p\)-values are systematically smaller than the corrected ones, which is the anti-conservatism measured in the null simulation above, now visible on real data.
holdout_tab <- data.frame(
model = c("Majority class", names(fits)),
accuracy = round(c(NIR_qol, vapply(fits,
\(m) mean(predict(m, qol_test) == qol_test$cd), numeric(1))), 4),
AUC = round(c(0.5, vapply(fits, \(m) as.numeric(pROC::auc(pROC::roc(
qol_test$cd, predict(m, qol_test, type = "prob")[, "severe_disease"],
quiet = TRUE))), numeric(1))), 4),
Brier = round(c(NA, vapply(fits, \(m) mean((
predict(m, qol_test, type = "prob")[, "severe_disease"] -
as.integer(qol_test$cd == "severe_disease"))^2), numeric(1))), 4))
holdout_tab$calib_slope <- round(c(NA, vapply(fits, function(m) {
p <- pmin(pmax(predict(m, qol_test, type = "prob")[, "severe_disease"],
1e-6), 1 - 1e-6)
unname(coef(glm(as.integer(qol_test$cd == "severe_disease") ~ qlogis(p),
family = binomial()))[2])
}, numeric(1))), 3)
holdout_tab$acc_CI <- c(NA, vapply(fits, function(m) {
ci <- binom.test(sum(predict(m, qol_test) == qol_test$cd),
nrow(qol_test))$conf.int
sprintf("[%.3f, %.3f]", ci[1], ci[2])
}, character(1)))
holdout_tab#> model accuracy AUC Brier calib_slope acc_CI
#> Majority class 0.5011 0.5000 NA NA <NA>
#> RF RF 0.6879 0.7236 0.2107 1.097 [0.642, 0.731]
#> kNN kNN 0.5649 0.6053 0.2427 0.680 [0.519, 0.614]
#> SVM SVM 0.6424 0.6969 0.2244 1.736 [0.596, 0.687]
#> GBM GBM 0.6879 0.7490 0.2051 1.299 [0.642, 0.731]
#> GLM GLM 0.6173 0.6753 0.2295 0.996 [0.570, 0.663]
Read the last three columns together. AUC ranks the models; the Brier score and calibration slope say whether their probabilities can be believed; and the confidence interval says whether the AUC differences are resolvable at this sample size. A model can lead on AUC and trail badly on calibration, which is a reason to prefer a different one if the output feeds a decision rule.
A disappointing CV score has two very different causes, and the remedy differs completely. A learning curve, training and validation error against training-set size, tells you which you have.
| Pattern | Diagnosis | Remedy |
|---|---|---|
| Both curves high, converged, small gap | High bias (underfitting) | More features, more capacity, less regularization |
| Training low, validation high, wide gap still closing | High variance (overfitting) | More data, more regularization, fewer features |
| Both low, converged | Model is doing what it can | Look at the irreducible noise |
set.seed(141)
n_lc <- 800; p_lc <- 12
X_lc <- matrix(rnorm(n_lc * p_lc), n_lc, p_lc)
# A nonlinear truth, so a linear model is bias-limited
y_lc <- X_lc[, 1]^2 + 2 * X_lc[, 2] + 0.5 * X_lc[, 3] * X_lc[, 4] +
rnorm(n_lc, sd = 1)
d_lc <- data.frame(X_lc, y = y_lc)
te_idx <- 601:800; tr_pool <- 1:600
learning_curve <- function(fitter, label, sizes = c(20, 40, 80, 150, 300, 600)) {
do.call(rbind, lapply(sizes, function(m) {
reps <- vapply(1:25, function(r) {
set.seed(r * 100 + m)
idx <- sample(tr_pool, m)
fit <- fitter(d_lc[idx, ])
c(train = sqrt(mean((predict(fit, d_lc[idx, ]) - d_lc$y[idx])^2)),
valid = sqrt(mean((predict(fit, d_lc[te_idx, ]) - d_lc$y[te_idx])^2)))
}, numeric(2))
data.frame(m = m, Training = mean(reps["train", ]),
Validation = mean(reps["valid", ]), model = label)
}))
}
lc <- bind_rows(
learning_curve(\(d) lm(y ~ ., data = d), "Linear (high bias)"),
learning_curve(\(d) rpart::rpart(y ~ ., d,
control = rpart::rpart.control(cp = 0, minsplit = 2)), "Deep tree (high variance)"))
lc |> pivot_longer(c(Training, Validation), names_to = "set", values_to = "rmse") |>
ggplot(aes(m, rmse, color = set)) +
geom_line(linewidth = 1) + geom_point(size = 2) +
facet_wrap(~ model) +
scale_x_log10() +
scale_color_manual(values = c(Training = "#7FB069", Validation = "#3B7DD8")) +
labs(title = "Learning curves diagnose bias versus variance",
subtitle = "Left: curves converge high and close -- more data will not help. Right: wide gap still closing -- it will",
x = "Training set size (log scale)", y = "RMSE", color = NULL) +
theme_dspa(10)# --- Interactive equivalent ------------------------------------------------
d <- filter(lc, model == "Deep tree (high variance)")
plot_ly(d, x = ~m, y = ~Training, type = "scatter", mode = "lines+markers",
name = "Training") |>
add_trace(y = ~Validation, name = "Validation") |>
layout(title = "Learning curve",
xaxis = list(title = "Training set size", type = "log"),
yaxis = list(title = "RMSE"), legend = list(orientation = "h"))The two panels prescribe opposite actions. On the left, training and validation error have converged at a high level, the model class cannot represent the truth, and collecting more data changes nothing. On the right the gap is wide and still narrowing, more data will help, and so will regularization.
The full picture is a surface over (training size, model complexity):
sizes_ls <- c(25, 50, 100, 200, 400, 600)
depths <- c(1, 2, 3, 5, 8, 15)
Zls <- outer(depths, sizes_ls, Vectorize(function(dp, m) {
mean(vapply(1:12, function(r) {
set.seed(r * 77 + m + dp)
idx <- sample(tr_pool, m)
fit <- rpart::rpart(y ~ ., d_lc[idx, ],
control = rpart::rpart.control(maxdepth = dp, cp = 0, minsplit = 5))
sqrt(mean((predict(fit, d_lc[te_idx, ]) - d_lc$y[te_idx])^2))
}, numeric(1)))
}))
plot_ly(x = sizes_ls, y = depths, z = Zls, type = "surface",
colorscale = "Viridis", reversescale = TRUE,
colorbar = list(title = "Validation\nRMSE")) |>
layout(title = "Validation error over training size and tree depth",
scene = list(xaxis = list(title = "Training set size"),
yaxis = list(title = "Maximum tree depth"),
zaxis = list(title = "Validation RMSE")))Rotate to the small-sample edge: the optimal depth there is shallow, and it grows as data accumulate. The right complexity is a function of the sample size, which is why a hyperparameter tuned on one dataset does not transfer to a smaller one.
Grid search evaluates every combination: \(\prod_j m_j\) fits for \(m_j\) values of parameter \(j\), exponential in the number of parameters. Random search draws configurations from distributions over the same space.
Random search is usually better for the same budget (Bergstra & Bengio, 2012). The reason is effective dimensionality: most hyperparameters barely matter, and a grid spends its budget re-evaluating the same few values of the ones that do. With \(m\) points on a grid over \(d\) parameters, only \(m^{1/d}\) distinct values of each parameter are tried; random search tries \(m\) distinct values of each.
set.seed(151)
# A response depending strongly on one parameter and weakly on another
f_hyper <- function(a, b) -( (a - 0.3)^2 * 10 + (b - 0.7)^2 * 0.15 )
budget <- 36
g <- expand.grid(a = seq(0, 1, length.out = 6), b = seq(0, 1, length.out = 6))
r <- data.frame(a = runif(budget), b = runif(budget))
c(grid_best = round(max(f_hyper(g$a, g$b)), 4),
random_best = round(max(f_hyper(r$a, r$b)), 4),
distinct_a_values_grid = length(unique(g$a)),
distinct_a_values_random = length(unique(r$a)))#> grid_best random_best distinct_a_values_grid
#> -0.1015 -0.0013 6.0000
#> distinct_a_values_random
#> 36.0000
bind_rows(mutate(g, search = "Grid (6 x 6)"),
mutate(r, search = "Random (36 draws)")) |>
ggplot(aes(a, b)) +
geom_raster(data = expand.grid(a = seq(0, 1, 0.01), b = seq(0, 1, 0.01)) |>
mutate(z = f_hyper(a, b)), aes(fill = z), alpha = 0.75) +
geom_point(size = 1.8, color = "white") +
facet_wrap(~ search) +
scale_fill_viridis_c(guide = "none") +
coord_fixed() +
labs(title = "Grid search wastes its budget on the parameter that does not matter",
subtitle = "Objective varies steeply in a and weakly in b. Grid tries 6 values of a; random tries 36",
x = "a (important)", y = "b (unimportant)") +
theme_dspa(10)Selecting the configuration with the best CV score picks the maximum of a set of noisy estimates. The 1-SE rule picks the simplest model whose score is within one standard error of the best, trading a statistically indistinguishable amount of performance for a smaller, more stable model.
set.seed(161)
ctrl_1se <- trainControl(method = "repeatedcv", number = 10, repeats = 3,
selectionFunction = "oneSE")
grid_k <- expand.grid(k = seq(1, 41, by = 4))
knn_best <- train(cd ~ ., data = qol_train, method = "knn", metric = "Kappa",
trControl = trainControl(method = "repeatedcv", number = 10,
repeats = 3),
tuneGrid = grid_k, preProcess = c("center", "scale"))
knn_1se <- train(cd ~ ., data = qol_train, method = "knn", metric = "Kappa",
trControl = ctrl_1se, tuneGrid = grid_k,
preProcess = c("center", "scale"))
c(best_k = knn_best$bestTune$k, oneSE_k = knn_1se$bestTune$k)#> best_k oneSE_k
#> 37 41
kres <- knn_best$results
best_row <- kres[which.max(kres$Kappa), ]
thresh <- best_row$Kappa - best_row$KappaSD / sqrt(30)
ggplot(kres, aes(k, Kappa)) +
geom_ribbon(aes(ymin = Kappa - KappaSD / sqrt(30),
ymax = Kappa + KappaSD / sqrt(30)), fill = "grey86") +
geom_line(linewidth = 1, color = "steelblue") + geom_point(size = 2) +
geom_hline(yintercept = thresh, linetype = "dashed", color = "firebrick") +
geom_point(data = best_row, color = "firebrick", size = 4) +
geom_point(data = kres[kres$k == knn_1se$bestTune$k, ],
color = "darkgreen", size = 4, shape = 17) +
labs(title = "The one-standard-error rule",
subtitle = "Red: best score. Dashed: one SE below it. Green triangle: simplest model above the line",
x = "k", y = "Cross-validated Kappa") +
theme_dspa()# --- Interactive equivalent ------------------------------------------------
plot_ly(kres, x = ~k, y = ~Kappa, type = "scatter", mode = "lines+markers",
error_y = ~list(array = KappaSD / sqrt(30)), name = "CV Kappa") |>
add_lines(x = range(kres$k), y = rep(thresh, 2), name = "1-SE threshold",
line = list(dash = "dash", color = "red")) |>
layout(title = "Tuning k with the 1-SE rule",
xaxis = list(title = "k"), yaxis = list(title = "Kappa"))boystown <- dspa_read(
"https://umich.instructure.com/files/399119/download?download_frd=1",
"CaseStudy02_Boystown_Data.csv", sep = " ")
boystown$sex <- boystown$sex - 1
boystown$dadjob <- 2 - boystown$dadjob
boystown$momjob <- 2 - boystown$momjob
# The outcome is DERIVED from gpa
boystown$grade <- factor(boystown$gpa %in% c(3, 4, 5),
levels = c(FALSE, TRUE),
labels = c("above_avg", "avg_or_below"))
c(outcome_definition = "grade = gpa %in% c(3,4,5)",
correlation_gpa_grade = round(cor(boystown$gpa,
as.integer(boystown$grade) - 1), 4))#> outcome_definition correlation_gpa_grade
#> "grade = gpa %in% c(3,4,5)" "0.8243"
grade is a deterministic function of
gpa. Any model given gpa can reproduce the
outcome exactly.
norm01 <- function(x) (x - min(x)) / (max(x) - min(x))
bt_leaky <- boystown |> select(-any_of(c("id", "ID"))) |>
mutate(across(where(is.numeric), norm01))
bt_clean <- bt_leaky |> select(-gpa) # remove the outcome's source
set.seed(171)
ctrl_bt <- trainControl(method = "repeatedcv", number = 10, repeats = 3)
grid_bt <- expand.grid(k = c(1, 3, 5, 7, 9, 15, 21))
m_leaky <- train(grade ~ ., data = bt_leaky, method = "knn",
trControl = ctrl_bt, tuneGrid = grid_bt)
m_clean <- train(grade ~ ., data = bt_clean, method = "knn",
trControl = ctrl_bt, tuneGrid = grid_bt)
data.frame(
feature_set = c("with gpa (leaky)", "without gpa (honest)"),
best_k = c(m_leaky$bestTune$k, m_clean$bestTune$k),
cv_accuracy = round(c(max(m_leaky$results$Accuracy),
max(m_clean$results$Accuracy)), 4),
cv_kappa = round(c(max(m_leaky$results$Kappa),
max(m_clean$results$Kappa)), 4),
no_information_rate = round(max(prop.table(table(boystown$grade))), 4))#> feature_set best_k cv_accuracy cv_kappa no_information_rate
#> 1 with gpa (leaky) 7 0.8683 0.6702 0.67
#> 2 without gpa (honest) 21 0.6324 0.0253 0.67
Common misconception: “cross-validation protects against leakage.” It protects against overfitting the model to the training rows. It cannot protect against a feature that is the outcome. Every fold sees
gpa, every fold recoversgradefrom it, and the CV estimate is high and entirely honest about the wrong question, it correctly estimates how well the procedure predictsgradegivengpa, which is a question no one has.The check is not statistical but causal: would this feature be available, and mean the same thing, at the moment a prediction is actually needed?
\(n\) = observations, \(d\) = features, \(k\) = folds, \(r\) = repeats, \(B\) = bootstrap replicates, \(|\Lambda|\) = hyperparameter grid size, \(C(m)\) = cost of one fit on \(m\) rows.
| Task | Fits | Extra cost | Note |
|---|---|---|---|
| Training error | \(1\) | \(O(n)\) | Optimistic by \(2d\sigma^2/n\) |
| \(C_p\) / AIC / BIC | \(1\) | \(O(1)\) | Requires an estimate of \(d\) and \(\sigma^2\) |
| Effective df (linear smoother) | \(1\) | \(O(n^2)\) for \(\operatorname{tr}(S)\) | \(O(nd)\) if \(S\) is never formed |
| Holdout | \(1\) | — | Unbiased, high variance |
| \(k\)-fold CV | \(k\) | — | \(k=5\) or \(10\) standard |
| Repeated \(k\)-fold | \(rk\) | — | Reduces partition variance only |
| LOOCV (general) | \(n\) | — | Impractical beyond small \(n\) |
| LOOCV (linear smoother) | \(\mathbf{1}\) | \(O(n)\) for hat values | Sherman–Morrison shortcut |
| GCV | \(1\) | \(O(1)\) | Uses \(\operatorname{tr}(S)/n\) |
| Bootstrap OOB / .632 / .632+ | \(B\) | \(O(Bn)\) | \(B=50\)–\(200\); plus \(\sim50\) fits for \(\hat\gamma\) |
| Nested CV | \(k_{\text{out}}(1+k_{\text{in}}\lvert\Lambda\rvert)\) | — | 255 fits at \(5\times5\times10\) |
| Grouped \(k\)-fold | \(k\) | — | Fold size set by groups |
| Rolling origin | #origins | \(\sum_\tau C(\tau)\) | Training sets grow |
| ROC / AUC | — | \(O(n\log n)\) | Sorting the scores |
| PR curve / PR-AUC | — | \(O(n\log n)\) | Same sort |
| Reliability diagram | — | \(O(n)\) | After binning |
| Platt scaling | — | \(O(n)\) per IRLS step | One logistic fit |
| Isotonic regression | — | \(O(n)\) | Pool-adjacent-violators after sorting |
| Decision curve | — | \(O(n\lvert T\rvert)\) | \(\lvert T\rvert\) thresholds |
| Corrected resampled \(t\) | — | \(O(k)\) | Variance inflation only |
Four consequences.
The LOOCV shortcut is a three-order-of-magnitude saving, and only for linear smoothers. \(\hat{\mathbf y}=S\mathbf y\) with \(S\) independent of \(\mathbf y\) is the precondition. Ridge, smoothing splines, and kernel smoothers qualify; random forests, boosting, and neural networks do not.
Nested CV costs a factor of \(k_{\text{in}}\lvert\Lambda\rvert\) over flat CV. That is the price of an unbiased estimate after tuning, and it is why flat CV is appropriate for exploration while nested CV is reserved for the number you report.
Metrics are cheap; resampling is not. Every curve and score in Part II costs \(O(n\log n)\) at worst. All of the expense is in refitting.
Recalibration is nearly free. One extra logistic fit or one isotonic pass on held-out predictions, and it improves the Brier score and calibration slope without touching AUC.
| # | Pitfall | Consequence | Fix |
|---|---|---|---|
| 1 | Reporting training error | Optimistic by \(2d\sigma^2/n\) | Resample, or correct with \(C_p\)/AIC |
| 2 | Counting parameters as complexity | Wrong for regularized or ensemble models | Effective df \(=\frac1{\sigma^2}\sum\operatorname{Cov}(\hat y_i,y_i)\) |
| 3 | confusionMatrix(truth, pred) |
Transposes; swaps sensitivity and specificity | confusionMatrix(data = pred, reference = truth, positive = ...) |
| 4 | Calling \(1-\)precision the Type I error | Confuses FDR with \(\alpha\) | Type I conditions on truth; FDR on the prediction |
| 5 | Writing \(1-\text{recall}=FN/(TN+FN)\) | That is the false omission rate | \(1-\text{recall}=FN/(TP+FN)\) |
| 6 | Reading PPV as a property of the test | PPV depends on prevalence | Sens/spec are the test; PPV/NPV are the test in a population |
| 7 | Interpreting \(\kappa\) without the prevalence | Same test gives very different \(\kappa\) | Report prevalence; consider MCC |
| 8 | Assuming \(\kappa\in[0,1]\) | \(\kappa\) can be negative | Range is \([-1,1]\); the floor depends on marginals |
| 9 | Weighted \(\kappa\) on nominal categories | Nothing to weight; needs an ordering | Weighted \(\kappa\) is for ordinal outcomes |
| 10 | Reporting ROC AUC beside a PR curve | Different quantities, different chance levels | PRROC::pr.curve() /
yardstick::pr_auc() |
| 11 | Using ROC AUC under heavy imbalance | \(FP\) barely moves the FPR | PR curve; report prevalence as the PR chance level |
| 12 | Treating AUC as evidence of good probabilities | AUC is invariant to monotone distortion | Reliability diagram, Brier, calibration slope |
| 13 | Optimizing accuracy to pick probabilities | Accuracy is not a proper scoring rule | Brier or log-loss |
| 14 | Fitting the recalibration map on training predictions | Learns in-sample over-confidence | Fit on held-out scores |
| 15 | Reporting correlation as a regression metric | Affine-invariant; \(\hat y=100+0.01y\) scores 1 | RMSE, MAE, held-out \(R^2\), calibration slope |
| 16 | One holdout split as “the” estimate | Unbiased but very imprecise | Resample; report an interval |
| 17 | Choosing \(k\) without the tradeoff | Small \(k\) pessimistic; large \(k\) correlated | \(k=5\) or \(10\); repeat to stabilize |
| 18 | Unstratified folds under imbalance | A fold may contain no positives | strata = in every split |
| 19 | train.err in .632 taken from held-out data |
Both terms then estimate the same thing | The first term is the apparent error |
| 20 | OOB set built from 1:length(boot_indices) |
Tail rows never out-of-bag | setdiff(seq_len(n), idx) |
| 21 | Reporting the best of several CV scores | Maximum of noisy estimates is biased up | Nested CV |
| 22 | Random folds on grouped or temporal data | The model memorizes subject or interpolates time | group_vfold_cv; rolling origin |
| 23 | Naive paired \(t\)-test on CV folds | Folds are correlated; test is anti-conservative | Corrected resampled \(t\); adjust for multiplicity |
| 24 | Trusting CV to catch leakage | It estimates the right answer to the wrong question | Ask whether the feature exists at prediction time |
The identity \(\mathbb{E}[\mathrm{op}]=\frac2n\sum_i\operatorname{Cov}(\hat y_i,y_i)\) holds for any fitting procedure, not just linear ones. Verify it for a regression tree and compare its effective degrees of freedom to its leaf count.
set.seed(201)
n_p1 <- 100
X_p1 <- matrix(rnorm(n_p1 * 3), n_p1, 3)
mu_p1 <- X_p1[, 1] + 0.5 * X_p1[, 2]^2
sigma_p1 <- 1
fit_and_predict <- function(depth) {
reps <- 3000
yh <- matrix(NA_real_, n_p1, reps); ys <- matrix(NA_real_, n_p1, reps)
eb <- ein <- numeric(reps)
for (r in seq_len(reps)) {
y <- mu_p1 + rnorm(n_p1, sd = sigma_p1)
d <- data.frame(X_p1, y = y)
f <- rpart::rpart(y ~ ., d,
control = rpart::rpart.control(maxdepth = depth, cp = 0, minsplit = 5))
p <- predict(f, d)
yn <- mu_p1 + rnorm(n_p1, sd = sigma_p1)
yh[, r] <- p; ys[, r] <- y
eb[r] <- mean((y - p)^2); ein[r] <- mean((yn - p)^2)
}
cov_sum <- sum(vapply(seq_len(n_p1), \(i) cov(yh[i, ], ys[i, ]), numeric(1)))
c(depth = depth,
observed_optimism = mean(ein - eb),
two_over_n_times_cov = 2 * cov_sum / n_p1,
effective_df = cov_sum / sigma_p1^2,
leaves = sum(rpart::rpart(y ~ ., data.frame(X_p1, y = ys[, 1]),
control = rpart::rpart.control(maxdepth = depth, cp = 0,
minsplit = 5))$frame$var == "<leaf>"))
}
as.data.frame(do.call(rbind, lapply(c(1, 2, 4), fit_and_predict))) |>
mutate(across(where(is.numeric), \(z) round(z, 3)))#> depth observed_optimism two_over_n_times_cov effective_df leaves
#> 1 1 0.218 0.222 11.105 2
#> 2 2 0.393 0.402 20.076 4
#> 3 4 0.737 0.738 36.894 12
The identity holds to two decimals at every depth. Note the last two
columns: effective df substantially exceeds the leaf
count, because the tree also spent degrees of freedom
choosing where to split, a cost invisible to any parameter
count.
Construct several monotone distortions of one score vector and confirm that AUC is invariant while every proper scoring rule is not.
set.seed(203)
n2 <- 3000
p0 <- plogis(rnorm(n2))
y2 <- rbinom(n2, 1, p0)
distortions <- list(
identity = \(p) p,
`p^3` = \(p) p^3,
`sqrt(p)` = \(p) sqrt(p),
`logit x 4`= \(p) plogis(4 * qlogis(p)),
`logit + 2`= \(p) plogis(qlogis(p) + 2))
do.call(rbind, lapply(names(distortions), function(nm) {
q <- pmin(pmax(distortions[[nm]](p0), 1e-9), 1 - 1e-9)
data.frame(distortion = nm,
AUC = round(as.numeric(pROC::auc(pROC::roc(y2, q, quiet = TRUE))), 6),
Brier = round(mean((q - y2)^2), 5),
log_loss = round(-mean(y2 * log(q) + (1 - y2) * log(1 - q)), 5),
calib_slope = round(unname(coef(glm(y2 ~ qlogis(q),
family = binomial()))[2]), 4))
}))#> distortion AUC Brier log_loss calib_slope
#> 1 identity 0.731234 0.20904 0.60374 0.9912
#> 2 p^3 0.731234 0.31251 0.98022 0.5606
#> 3 sqrt(p) 0.731234 0.24696 0.69183 1.1682
#> 4 logit x 4 0.731234 0.25956 0.92902 0.2478
#> 5 logit + 2 0.731234 0.33730 0.97787 0.9912
Every AUC is identical to six decimals; every Brier
score, log-loss, and calibration slope differs. AUC depends only on the
ordering, which a monotone map preserves exactly.
Construct two confusion matrices with the same \(\kappa\) and very different accuracy, and two with the same accuracy and very different \(\kappa\).
kstats <- function(TP, FP, FN, TN) {
n <- TP + FP + FN + TN
p_o <- (TP + TN) / n
p_e <- ((TP + FP) * (TP + FN) + (TN + FN) * (TN + FP)) / n^2
c(accuracy = round(p_o, 4), kappa = round((p_o - p_e) / (1 - p_e), 4),
prevalence = round((TP + FN) / n, 4))
}
rbind(
`same kappa, different accuracy (A)` = kstats(45, 15, 15, 25),
`same kappa, different accuracy (B)` = kstats(10, 8, 8, 174),
`same accuracy, different kappa (C)` = kstats(40, 10, 10, 40),
`same accuracy, different kappa (D)` = kstats(76, 4, 16, 4))#> accuracy kappa prevalence
#> same kappa, different accuracy (A) 0.70 0.3750 0.60
#> same kappa, different accuracy (B) 0.92 0.5116 0.09
#> same accuracy, different kappa (C) 0.80 0.6000 0.50
#> same accuracy, different kappa (D) 0.80 0.1935 0.92
Rows A and B have similar \(\kappa\) at
very different accuracies; rows C and D have identical accuracy at very
different \(\kappa\). Neither
statistic determines the other, and both depend on the
marginals, which is why the prevalence must accompany any reported \(\kappa\).
Reproduce the .632 estimate using a held-out error as the first term, and compare against the correct construction.
set.seed(205)
n4 <- 250
d4 <- data.frame(matrix(rnorm(n4 * 5), n4, 5))
d4$y <- factor(ifelse(d4$X1 + d4$X2 + rnorm(n4) > 0, "a", "b"))
tr4 <- 1:180; te4 <- 181:n4
fit4 <- fit_tree(y ~ ., d4[tr4, ])
apparent <- loss_01(d4[tr4, ], pred_tree(fit4, d4[tr4, ]))
heldout <- loss_01(d4[te4, ], pred_tree(fit4, d4[te4, ]))
# Correct OOB, built with setdiff
set.seed(207)
oob_losses <- matrix(NA_real_, length(tr4), 60)
for (b in 1:60) {
idx <- sample(tr4, length(tr4), replace = TRUE)
oob <- setdiff(tr4, idx)
if (!length(oob)) next
fb <- fit_tree(y ~ ., d4[idx, ])
oob_losses[match(oob, tr4), b] <- loss_01(d4[oob, ], pred_tree(fb, d4[oob, ]),
per_case = TRUE)
}
err_oob <- mean(rowMeans(oob_losses, na.rm = TRUE), na.rm = TRUE)
# WRONG OOB: indexing only into 1:length(idx)
set.seed(207)
wrong_oob <- mean(vapply(1:60, function(b) {
idx <- sample(length(tr4), floor(0.9 * length(tr4)), replace = TRUE)
i <- seq_along(idx); i <- i[is.na(match(i, idx))] # never exceeds 0.9n
if (!length(i)) return(NA_real_)
fb <- fit_tree(y ~ ., d4[tr4[idx], ])
loss_01(d4[tr4[i], ], pred_tree(fb, d4[tr4[i], ]))
}, numeric(1)), na.rm = TRUE)
c(apparent_error = round(apparent, 4),
true_heldout_error = round(heldout, 4),
correct_632 = round(0.368 * apparent + 0.632 * err_oob, 4),
using_heldout_as_first_term = round(0.368 * heldout + 0.632 * err_oob, 4),
using_wrong_oob_index = round(0.368 * apparent + 0.632 * wrong_oob, 4))#> apparent_error true_heldout_error
#> 0.0000 0.4286
#> correct_632 using_heldout_as_first_term
#> 0.2162 0.3739
#> using_wrong_oob_index
#> 0.2107
The correct construction lands close to the true held-out error.
Substituting a held-out error for the apparent one
over-corrects, both terms now describe out-of-sample
performance, so the blend has nothing optimistic to balance. The
malformed index set produces a third, different number.
Estimate the false-positive rate of the paired \(t\)-test on CV folds under a true null, as a function of \(k\).
set.seed(209)
null_rate_k <- function(k, reps = 300, n = 200) {
rej <- vapply(seq_len(reps), function(r) {
X <- matrix(rnorm(n * 4), n, 4)
y <- as.vector(X %*% c(1, 0.5, 0, 0)) + rnorm(n)
d <- data.frame(X, y = y)
f <- sample(rep(seq_len(k), length.out = n))
dif <- vapply(seq_len(k), function(i) {
tr <- d[f != i, ]; te <- d[f == i, ]
# Two models that differ only trivially (an irrelevant extra feature)
a <- mean((predict(lm(y ~ X1 + X2, tr), te) - te$y)^2)
b <- mean((predict(lm(y ~ X1 + X2 + X3, tr), te) - te$y)^2)
a - b
}, numeric(1))
c(naive = abs(mean(dif) / (sd(dif) / sqrt(k))) > qt(0.975, k - 1),
corrected = abs(mean(dif) / sqrt((1/k + 1/(k-1)) * var(dif))) > qt(0.975, k - 1))
}, logical(2))
c(k = k, naive = mean(rej["naive", ]), corrected = mean(rej["corrected", ]))
}
nr <- as.data.frame(do.call(rbind, lapply(c(5, 10, 20), null_rate_k)))
round(nr, 4)#> k naive corrected
#> 1 5 0.0700 0.0067
#> 2 10 0.1633 0.0267
#> 3 20 0.2400 0.0967
nr |> pivot_longer(-k, names_to = "test", values_to = "rate") |>
ggplot(aes(factor(k), rate, fill = test)) +
geom_col(position = "dodge") +
geom_hline(yintercept = 0.05, linetype = "dashed", color = "black") +
scale_fill_manual(values = c(naive = "#D8433B", corrected = "#3B7DD8")) +
labs(title = "False-positive rate under a true null",
subtitle = "Dashed line: the nominal 5% level",
x = "Folds k", y = "Rejection rate", fill = NULL) +
theme_dspa()Verify the hat-value formula for ridge regression and use it to select \(\lambda\) in one pass.
set.seed(211)
n6 <- 150; p6 <- 25
X6 <- scale(matrix(rnorm(n6 * p6), n6, p6))
y6 <- as.vector(X6 %*% c(rep(1.5, 5), rep(0, p6 - 5))) + rnorm(n6, sd = 2)
ridge_loocv <- function(lambda) {
S <- X6 %*% solve(crossprod(X6) + lambda * diag(p6)) %*% t(X6)
yh <- S %*% y6
h <- diag(S)
c(loocv = mean(((y6 - yh) / (1 - h))^2),
gcv = mean(((y6 - yh) / (1 - sum(h) / n6))^2),
edf = sum(h))
}
lams6 <- 10^seq(-2, 4, length.out = 40)
r6 <- as.data.frame(t(vapply(lams6, ridge_loocv, numeric(3))))
r6$lambda <- lams6
# Brute-force check at one lambda
lam_chk <- 10
brute <- mean(vapply(seq_len(n6), function(i) {
b <- solve(crossprod(X6[-i, ]) + lam_chk * diag(p6), crossprod(X6[-i, ], y6[-i]))
(y6[i] - X6[i, ] %*% b)^2
}, numeric(1)))
c(closed_form = round(ridge_loocv(lam_chk)[["loocv"]], 6),
brute_force = round(brute, 6),
lambda_min_loocv = round(lams6[which.min(r6$loocv)], 3),
lambda_min_gcv = round(lams6[which.min(r6$gcv)], 3))#> closed_form brute_force lambda_min_loocv lambda_min_gcv
#> 4.48745 4.48745 11.93800 11.93800
r6 |> select(lambda, LOOCV = loocv, GCV = gcv) |>
pivot_longer(-lambda, names_to = "criterion", values_to = "value") |>
ggplot(aes(lambda, value, color = criterion)) +
geom_line(linewidth = 1) + scale_x_log10() +
scale_color_manual(values = c(LOOCV = "#3B7DD8", GCV = "#D8433B")) +
labs(title = "LOOCV and GCV for ridge regression, from one fit each",
x = expression(lambda~"(log scale)"), y = "Criterion", color = NULL) +
theme_dspa()Quantify the optimism of random folds as a function of the intraclass correlation.
set.seed(213)
grouped_gap <- function(icc, n_sub = 40, per = 5) {
se <- rnorm(n_sub, sd = sqrt(icc))
res <- rnorm(n_sub * per, sd = sqrt(1 - icc))
subj <- rep(seq_len(n_sub), each = per)
y <- factor(ifelse(se[subj] + res > 0, "a", "b"))
Xg <- cbind(se[subj] + rnorm(n_sub * per, sd = 0.4),
matrix(rnorm(n_sub * per * 2), ncol = 2))
d <- data.frame(Xg, subject = subj, y = y)
acc <- function(folds) mean(vapply(folds, function(te) {
tr <- d[-te, ]
mean(class::knn(tr[, 1:3], d[te, 1:3], tr$y, k = 5) == d$y[te])
}, numeric(1)))
fr <- split(sample(nrow(d)), rep(1:5, length.out = nrow(d)))
gs <- split(seq_len(n_sub), rep(1:5, length.out = n_sub))
fg <- lapply(gs, \(s) which(d$subject %in% s))
c(icc = icc, random = acc(fr), grouped = acc(fg))
}
gg <- as.data.frame(do.call(rbind, lapply(c(0.05, 0.2, 0.5, 0.8), grouped_gap)))
gg$optimism <- gg$random - gg$grouped
round(gg, 4)#> icc random grouped optimism
#> 1 0.05 0.535 0.455 0.080
#> 2 0.20 0.545 0.565 -0.020
#> 3 0.50 0.635 0.630 0.005
#> 4 0.80 0.710 0.680 0.030
gg |> select(icc, Random = random, Grouped = grouped) |>
pivot_longer(-icc, names_to = "folds", values_to = "accuracy") |>
ggplot(aes(icc, accuracy, color = folds)) +
geom_line(linewidth = 1) + geom_point(size = 2.4) +
scale_color_manual(values = c(Random = "#D8433B", Grouped = "#3B7DD8")) +
labs(title = "Optimism of random folds grows with the intraclass correlation",
subtitle = "Grouped folds keep every subject's observations on one side of the split",
x = "Intraclass correlation", y = "CV accuracy", color = NULL) +
theme_dspa()Compare net benefit before and after recalibration, and explain why the decision curve moves when the ROC curve does not.
set.seed(215)
n8 <- 4000
x8 <- rnorm(n8)
pt8 <- plogis(-2.0 + 1.3 * x8)
y8 <- rbinom(n8, 1, pt8)
p_bad <- plogis(3 * qlogis(pt8)) # over-confident
cal8 <- sample(n8, n8 / 2); ev8 <- setdiff(seq_len(n8), cal8)
fit8 <- glm(y8[cal8] ~ qlogis(p_bad[cal8]), family = binomial())
p_fix <- plogis(coef(fit8)[1] + coef(fit8)[2] * qlogis(p_bad[ev8]))
th8 <- seq(0.02, 0.45, by = 0.005)
pi8 <- mean(y8[ev8])
c(AUC_before = round(as.numeric(pROC::auc(pROC::roc(y8[ev8], p_bad[ev8],
quiet = TRUE))), 4),
AUC_after = round(as.numeric(pROC::auc(pROC::roc(y8[ev8], p_fix,
quiet = TRUE))), 4),
Brier_before = round(mean((p_bad[ev8] - y8[ev8])^2), 4),
Brier_after = round(mean((p_fix - y8[ev8])^2), 4))#> AUC_before AUC_after Brier_before Brier_after
#> 0.7923 0.7923 0.1221 0.1094
bind_rows(
data.frame(t = th8, nb = net_benefit(p_bad[ev8], y8[ev8], th8),
model = "Uncalibrated"),
data.frame(t = th8, nb = net_benefit(p_fix, y8[ev8], th8),
model = "Recalibrated"),
data.frame(t = th8, nb = pi8 - (1 - pi8) * th8 / (1 - th8), model = "Treat all"),
data.frame(t = th8, nb = 0, model = "Treat none")) |>
ggplot(aes(t, nb, color = model)) +
geom_line(linewidth = 0.9) +
coord_cartesian(ylim = c(-0.03, NA)) +
scale_color_manual(values = c(Uncalibrated = "#D8433B",
Recalibrated = "#3B7DD8",
`Treat all` = "grey45", `Treat none` = "black")) +
labs(title = "Recalibration changes decisions even though it cannot change AUC",
subtitle = "Net benefit depends on WHERE a probability falls relative to the threshold, not just on its rank",
x = expression("Threshold probability "*p[t]), y = "Net benefit",
color = NULL) +
theme_dspa()setdiff(seq_len(n), boot_indices)).What we are estimating
Metrics
Resampling
Comparison and improvement
Where these threads continue
| Thread | Continues in |
|---|---|
| Regularization paths, LASSO, stability selection | Feature selection |
| Rolling origin and forecast evaluation | Longitudinal analysis |
| Hyperparameter search as optimization | Function optimization |
| Validation for representation learning | Deep learning |
dspa_read(), simulation.#> R version 4.3.3 (2024-02-29 ucrt)
#> Platform: x86_64-w64-mingw32/x64 (64-bit)
#> Running under: Windows 11 x64 (build 26200)
#>
#> Matrix products: default
#>
#>
#> locale:
#> [1] LC_COLLATE=English_United States.utf8
#> [2] LC_CTYPE=English_United States.utf8
#> [3] LC_MONETARY=English_United States.utf8
#> [4] LC_NUMERIC=C
#> [5] LC_TIME=English_United States.utf8
#>
#> time zone: America/New_York
#> tzcode source: internal
#>
#> attached base packages:
#> [1] parallel stats graphics grDevices utils datasets methods
#> [8] base
#>
#> other attached packages:
#> [1] doParallel_1.0.17 iterators_1.0.14 foreach_1.5.2 rpart_4.1.23
#> [5] PRROC_1.4 rlang_1.1.5 pROC_1.18.5 caret_6.0-94
#> [9] lattice_0.22-6 yardstick_1.3.1 rsample_1.2.1 plotly_4.12.1
#> [13] patchwork_1.3.0 tidyr_1.3.1 dplyr_1.1.4 ggplot2_4.0.1
#>
#> loaded via a namespace (and not attached):
#> [1] tidyselect_1.2.1 viridisLite_0.4.2 timeDate_4032.109
#> [4] farver_2.1.2 S7_0.2.1 fastmap_1.2.0
#> [7] digest_0.6.37 timechange_0.3.0 lifecycle_1.0.5
#> [10] survival_3.7-0 kernlab_0.9-32 magrittr_2.0.3
#> [13] compiler_4.3.3 sass_0.4.9 tools_4.3.3
#> [16] yaml_2.3.10 data.table_1.16.4 knitr_1.51
#> [19] labeling_0.4.3 htmlwidgets_1.6.4 plyr_1.8.9
#> [22] RColorBrewer_1.1-3 withr_3.0.2 purrr_1.0.2
#> [25] nnet_7.3-19 grid_4.3.3 stats4_4.3.3
#> [28] e1071_1.7-14 future_1.33.2 globals_0.16.3
#> [31] scales_1.4.0 MASS_7.3-60.0.1 cli_3.6.3
#> [34] rmarkdown_2.31 generics_0.1.3 otel_0.2.0
#> [37] rstudioapi_0.18.0 future.apply_1.11.2 httr_1.4.7
#> [40] reshape2_1.4.4 cachem_1.1.0 proxy_0.4-27
#> [43] stringr_1.5.1 splines_4.3.3 vctrs_0.6.5
#> [46] hardhat_1.4.3 Matrix_1.6-5 jsonlite_1.8.9
#> [49] listenv_0.9.1 crosstalk_1.2.1 gower_1.0.1
#> [52] jquerylib_0.1.4 recipes_1.4.0 glue_1.8.0
#> [55] parallelly_1.37.1 codetools_0.2-20 lubridate_1.9.3
#> [58] stringi_1.8.4 gtable_0.3.6 tibble_3.2.1
#> [61] furrr_0.3.1 pillar_1.10.1 htmltools_0.5.8.1
#> [64] ipred_0.9-14 gbm_2.3.1 lava_1.8.0
#> [67] R6_2.6.1 evaluate_1.0.3 bslib_0.9.0
#> [70] class_7.3-22 Rcpp_1.0.14 nlme_3.1-165
#> [73] prodlim_2024.06.25 ranger_0.16.0 xfun_0.52
#> [76] pkgconfig_2.0.3 ModelMetrics_1.2.2.2