This note is designed as a foundation module before Bayesian inference. The aim is to motivate researchers in basic sciences—physics, astronomy, geoscience, biology, materials science, and environmental science—to see statistical inference and machine learning as complementary components of data science.
We assume basic probability, but we carefully explain the statistical notation, uncertainty language, stochastic-process viewpoint, and filtering idea. Bayesian inference is intentionally kept mostly outside this note; it can come as the next lecture. Here the focus is:
The real-data examples use small built-in R datasets so that students can run everything immediately.
In many scientific problems, data science is not merely applying a software package. It has four conceptual layers.
| Layer | Scientific meaning | Statistical/ML object |
|---|---|---|
| Data layer | What was measured? Under what instrument, design, and noise? | random variables, sampling scheme, missingness |
| Model layer | What scientific structure is being represented? | regression, classification, time series, stochastic process |
| Inference layer | What can be learned, and with what uncertainty? | estimators, confidence intervals, tests, residuals |
| Prediction/decision layer | What will happen next, or what should be done? | prediction error, cross-validation, risk, control/filtering |
A scientific data analysis fails when these layers are confused. For example, a model may have high predictive accuracy but answer the wrong scientific question; or a beautiful mechanistic model may have invalid uncertainty because its assumptions are wrong.
Let \[ D_n = \{(X_i,Y_i): i=1,\ldots,n\} \] be observed data. Here:
A statistical model usually says that the data distribution belongs to a family \[ P_\theta, \qquad \theta \in \Theta, \] where \(\theta\) is an unknown parameter or collection of parameters.
An estimand is the scientific target, for example:
An estimator is a data-dependent rule: \[ \widehat\theta = T(D_n). \]
A basic frequentist uncertainty statement studies the distribution of \(\widehat\theta\) under repeated sampling: \[ \widehat\theta - \theta. \]
Three important quantities are: \[ \operatorname{Bias}(\widehat\theta)=E(\widehat\theta)-\theta, \] \[ \operatorname{Var}(\widehat\theta)=E\{\widehat\theta-E(\widehat\theta)\}^2, \] \[ \operatorname{MSE}(\widehat\theta)=E(\widehat\theta-\theta)^2 =\operatorname{Bias}^2+\operatorname{Var}. \]
Statistics and machine learning overlap heavily, but their emphasis is different.
| Question | Statistical inference emphasis | Machine learning emphasis |
|---|---|---|
| What is the target? | estimand, parameter, scientific effect | prediction function, classifier, score |
| What is uncertainty? | sampling uncertainty, standard errors, confidence intervals | generalization error, validation error, calibration |
| What is a good model? | interpretable, identifiable, well-checked | predictive, flexible, scalable |
| Main danger | wrong assumptions, underfitting, misinterpreting p-values | overfitting, shortcut learning, extrapolation failure |
| Scientific strength | effect estimation and uncertainty | pattern recognition and flexible prediction |
| Best use together | estimand + uncertainty + prediction + validation | ML learns structure; statistics audits uncertainty |
A useful working principle is:
Statistics asks: what can we infer, with what uncertainty, under what assumptions? ML asks: what can we predict, and how well does it generalize? Data science needs both.
We simulate a nonlinear scientific relationship. A linear model is interpretable but misspecified; a polynomial model is more flexible; a tree is a simple ML model. This illustrates that prediction and inference are related but not identical.
set.seed(101)
n <- 90
sim <- data.frame(x = runif(n, -3, 3))
sim$f_true <- with(sim, sin(1.4 * x) + 0.25 * x)
sim$y <- sim$f_true + rnorm(n, sd = 0.35)
train_id <- sample(seq_len(n), size = 65)
train <- sim[train_id, ]
test <- sim[-train_id, ]
fit_lm <- lm(y ~ x, data = train)
fit_poly <- lm(y ~ poly(x, 5, raw = TRUE), data = train)
fit_tree <- rpart::rpart(y ~ x, data = train, control = rpart::rpart.control(cp = 0.01))
model_score <- data.frame(
model = c("Linear statistical model", "Polynomial regression", "ML regression tree"),
test_RMSE = c(
rmse(test$y, predict(fit_lm, newdata = test)),
rmse(test$y, predict(fit_poly, newdata = test)),
rmse(test$y, predict(fit_tree, newdata = test))
),
test_MAE = c(
mae(test$y, predict(fit_lm, newdata = test)),
mae(test$y, predict(fit_poly, newdata = test)),
mae(test$y, predict(fit_tree, newdata = test))
)
)
knitr::kable(model_score, digits = 3, caption = "Prediction performance on a held-out test set.")| model | test_RMSE | test_MAE |
|---|---|---|
| Linear statistical model | 0.736 | 0.653 |
| Polynomial regression | 0.346 | 0.261 |
| ML regression tree | 0.368 | 0.280 |
grid <- data.frame(x = seq(min(sim$x), max(sim$x), length.out = 300))
grid$f_true <- with(grid, sin(1.4 * x) + 0.25 * x)
lm_ci <- as.data.frame(predict(fit_lm, newdata = grid, interval = "confidence"))
poly_pred <- predict(fit_poly, newdata = grid)
tree_pred <- predict(fit_tree, newdata = grid)
grid$lm_fit <- lm_ci$fit
grid$lm_lwr <- lm_ci$lwr
grid$lm_upr <- lm_ci$upr
grid$poly <- poly_pred
grid$tree <- tree_pred
ggplot2::ggplot(sim, ggplot2::aes(x, y)) +
ggplot2::geom_point(alpha = 0.65) +
ggplot2::geom_line(data = grid, ggplot2::aes(x, f_true), linewidth = 1.1, linetype = 2) +
ggplot2::geom_ribbon(data = grid, ggplot2::aes(x = x, ymin = lm_lwr, ymax = lm_upr), alpha = 0.15, inherit.aes = FALSE) +
ggplot2::geom_line(data = grid, ggplot2::aes(x, lm_fit), linewidth = 1) +
ggplot2::geom_line(data = grid, ggplot2::aes(x, poly), linewidth = 1, linetype = 3) +
ggplot2::geom_line(data = grid, ggplot2::aes(x, tree), linewidth = 1, linetype = 4) +
ggplot2::labs(
title = "Statistical model, flexible regression, and ML tree on a nonlinear scientific relationship",
subtitle = "Dashed curve = true mean function; ribbon = linear-model confidence band",
y = "response", x = "scientific covariate"
)Interpretation. The linear model gives a clean slope and confidence band, but it is misspecified. The tree can adapt locally but gives no natural uncertainty interval. The polynomial model is flexible but can become unstable if the degree is too high. The lesson is not that one family wins always: the analyst must match the model to the scientific purpose.
Many ML methods produce point predictions. Researchers, however, often need uncertainty. A simple distribution-free idea is split conformal prediction. We train on one part of data, calibrate prediction residuals on a second part, and build an interval around future predictions.
set.seed(202)
idx <- sample(seq_len(nrow(sim)))
tr <- sim[idx[1:45], ]
cal <- sim[idx[46:65], ]
te <- sim[idx[66:nrow(sim)], ]
ml_tree <- rpart::rpart(y ~ x, data = tr, control = rpart::rpart.control(cp = 0.01))
cal_resid <- abs(cal$y - predict(ml_tree, newdata = cal))
alpha <- 0.10
q_level <- min(1, ceiling((length(cal_resid) + 1) * (1 - alpha)) / length(cal_resid))
qhat <- as.numeric(quantile(cal_resid, probs = q_level, type = 1))
te$pred <- predict(ml_tree, newdata = te)
te$lwr <- te$pred - qhat
te$upr <- te$pred + qhat
coverage <- mean(te$y >= te$lwr & te$y <= te$upr)
knitr::kable(
data.frame(n_train = nrow(tr), n_calibration = nrow(cal), n_test = nrow(te), qhat = qhat, empirical_coverage = coverage),
digits = 3,
caption = "Split conformal interval around a tree predictor."
)| n_train | n_calibration | n_test | qhat | empirical_coverage |
|---|---|---|---|---|
| 45 | 20 | 25 | 0.806 | 0.88 |
ggplot2::ggplot(te, ggplot2::aes(x, y)) +
ggplot2::geom_point() +
ggplot2::geom_errorbar(ggplot2::aes(ymin = lwr, ymax = upr), width = 0.03, alpha = 0.55) +
ggplot2::geom_point(ggplot2::aes(y = pred), shape = 4, size = 3) +
ggplot2::labs(
title = "ML prediction plus statistical calibration",
subtitle = "Crosses are ML predictions; vertical bars are conformal predictive intervals",
x = "x", y = "observed/predicted response"
)This is a good example of hand-in-hand data science: ML learns a flexible predictor, while statistical calibration turns the prediction into an uncertainty-aware statement.
We simulate a process that grows approximately exponentially. We train only on a limited experimental range and test outside that range. A tree cannot extrapolate beyond the training range. A physically motivated log-linear model can extrapolate better, but only because the transformation matches the scientific mechanism.
set.seed(303)
tr_ex <- data.frame(x = runif(80, 0, 3))
tr_ex$truth <- exp(0.55 * tr_ex$x)
tr_ex$y <- tr_ex$truth + rnorm(nrow(tr_ex), sd = 0.20)
tr_ex$y <- pmax(tr_ex$y, 0.05)
te_ex <- data.frame(x = seq(3.05, 5, length.out = 80))
te_ex$truth <- exp(0.55 * te_ex$x)
te_ex$y <- te_ex$truth + rnorm(nrow(te_ex), sd = 0.20)
te_ex$y <- pmax(te_ex$y, 0.05)
fit_naive_lm <- lm(y ~ x, data = tr_ex)
fit_log_lm <- lm(log(y) ~ x, data = tr_ex)
fit_ex_tree <- rpart::rpart(y ~ x, data = tr_ex)
te_ex$pred_linear <- predict(fit_naive_lm, newdata = te_ex)
te_ex$pred_loglinear <- exp(predict(fit_log_lm, newdata = te_ex))
te_ex$pred_tree <- predict(fit_ex_tree, newdata = te_ex)
ex_score <- data.frame(
model = c("Naive linear", "Physics-inspired log-linear", "ML tree"),
extrapolation_RMSE = c(
rmse(te_ex$y, te_ex$pred_linear),
rmse(te_ex$y, te_ex$pred_loglinear),
rmse(te_ex$y, te_ex$pred_tree)
)
)
knitr::kable(ex_score, digits = 3, caption = "Extrapolation error outside the training range.")| model | extrapolation_RMSE |
|---|---|
| Naive linear | 4.204 |
| Physics-inspired log-linear | 0.392 |
| ML tree | 6.036 |
grid_ex <- rbind(
dplyr::mutate(tr_ex, region = "training"),
dplyr::mutate(te_ex[, c("x", "truth", "y")], region = "extrapolation")
)
ggplot2::ggplot() +
ggplot2::geom_point(data = grid_ex, ggplot2::aes(x, y, shape = region), alpha = 0.55) +
ggplot2::geom_line(data = te_ex, ggplot2::aes(x, truth), linewidth = 1.1, linetype = 2) +
ggplot2::geom_line(data = te_ex, ggplot2::aes(x, pred_linear), linewidth = 1) +
ggplot2::geom_line(data = te_ex, ggplot2::aes(x, pred_loglinear), linewidth = 1, linetype = 3) +
ggplot2::geom_line(data = te_ex, ggplot2::aes(x, pred_tree), linewidth = 1, linetype = 4) +
ggplot2::labs(
title = "Extrapolation is a scientific assumption, not just an ML problem",
subtitle = "The tree is flexible inside the observed range but cannot infer the exponential law outside it",
x = "experimental condition", y = "response"
)Message. ML often excels at interpolation. Extrapolation requires structure. Statistical modelling can help when the structure is scientifically justified. But a wrong parametric structure can also fail. This is why scientific modelling is not reducible to software choice.
The faithful dataset contains waiting times between
eruptions and eruption durations for the Old Faithful geyser. It has 272
observations on two variables. The scientific question is: can eruption
duration predict the waiting time to the next eruption?
data(faithful)
faith <- faithful
set.seed(404)
train_id <- sample(seq_len(nrow(faith)), size = round(0.75 * nrow(faith)))
faith_train <- faith[train_id, ]
faith_test <- faith[-train_id, ]
faith_lm <- lm(waiting ~ eruptions, data = faith_train)
faith_lo <- loess(waiting ~ eruptions, data = faith_train, span = 0.75)
faith_tree <- rpart::rpart(waiting ~ eruptions, data = faith_train)
faith_score <- data.frame(
model = c("Linear inference model", "LOESS smoother", "ML tree"),
test_RMSE = c(
rmse(faith_test$waiting, predict(faith_lm, newdata = faith_test)),
rmse(faith_test$waiting, predict(faith_lo, newdata = faith_test)),
rmse(faith_test$waiting, predict(faith_tree, newdata = faith_test))
)
)
knitr::kable(faith_score, digits = 3, caption = "Prediction of waiting time for Old Faithful eruptions.")| model | test_RMSE |
|---|---|
| Linear inference model | 5.872 |
| LOESS smoother | 5.447 |
| ML tree | 5.542 |
faith_grid <- data.frame(eruptions = seq(min(faith$eruptions), max(faith$eruptions), length.out = 250))
faith_ci <- as.data.frame(predict(faith_lm, newdata = faith_grid, interval = "confidence"))
faith_grid$lm_fit <- faith_ci$fit
faith_grid$lm_lwr <- faith_ci$lwr
faith_grid$lm_upr <- faith_ci$upr
faith_grid$loess <- predict(faith_lo, newdata = faith_grid)
faith_grid$tree <- predict(faith_tree, newdata = faith_grid)
ggplot2::ggplot(faith, ggplot2::aes(eruptions, waiting)) +
ggplot2::geom_point(alpha = 0.55) +
ggplot2::geom_ribbon(data = faith_grid, ggplot2::aes(x = eruptions, ymin = lm_lwr, ymax = lm_upr), inherit.aes = FALSE, alpha = 0.15) +
ggplot2::geom_line(data = faith_grid, ggplot2::aes(eruptions, lm_fit), linewidth = 1) +
ggplot2::geom_line(data = faith_grid, ggplot2::aes(eruptions, loess), linewidth = 1, linetype = 3) +
ggplot2::geom_line(data = faith_grid, ggplot2::aes(eruptions, tree), linewidth = 1, linetype = 4) +
ggplot2::labs(
title = "Old Faithful geyser: statistical inference and ML-style flexible prediction",
subtitle = "Longer eruptions are associated with longer waiting times, but the relationship is not purely linear",
x = "eruption duration (minutes)", y = "waiting time to next eruption (minutes)"
)knitr::kable(
data.frame(term = names(coef(faith_lm)), estimate = coef(faith_lm), confint(faith_lm)),
digits = 3,
caption = "Linear model estimates with 95% confidence intervals."
)| term | estimate | X2.5.. | X97.5.. | |
|---|---|---|---|---|
| (Intercept) | (Intercept) | 33.247 | 30.565 | 35.930 |
| eruptions | eruptions | 10.794 | 10.071 | 11.518 |
Interpretation. The linear model gives an interpretable effect: a longer eruption is associated with a longer next waiting time. The smoother and tree capture nonlinearity. The statistical model gives uncertainty around the scientific effect; ML-style methods help discover flexible structure.
A stochastic process is a family of random variables indexed by time or space: \[ \{X_t: t\in T\}. \] Examples:
set.seed(505)
Tn <- 250
process_df <- data.frame(t = 1:Tn)
process_df$random_walk <- cumsum(rnorm(Tn, sd = 1))
process_df$ar1 <- as.numeric(arima.sim(list(ar = 0.85), n = Tn, sd = 1))
process_df$poisson_counts <- rpois(Tn, lambda = 4 + 2 * sin(2 * pi * (1:Tn) / 50))
long_proc <- tidyr::pivot_longer(process_df, cols = -t, names_to = "process", values_to = "value")
ggplot2::ggplot(long_proc, ggplot2::aes(t, value)) +
ggplot2::geom_line() +
ggplot2::facet_wrap(~process, scales = "free_y", ncol = 1) +
ggplot2::labs(
title = "Three basic stochastic processes",
subtitle = "Random walk, AR(1), and time-varying Poisson counts",
x = "time", y = "process value"
)Scientific translation. A time series is not merely a list of numbers. It may represent a dynamic system with memory, forcing, feedback, shocks, and measurement noise.
The Nile time series contains annual flow of the river
Nile at Aswan from 1871 to 1970, length 100, with an apparent change
point near 1898. We use it to show time-series inference, bad validation
practice, and filtering.
data(Nile)
nile_df <- data.frame(year = as.integer(time(Nile)), flow = as.numeric(Nile))
ggplot2::ggplot(nile_df, ggplot2::aes(year, flow)) +
ggplot2::geom_line() +
ggplot2::geom_vline(xintercept = 1898, linetype = 2) +
ggplot2::labs(
title = "River Nile annual flow",
subtitle = "A classical hydrological time series with apparent level shift near 1898",
x = "year", y = expression(paste("flow (", 10^8, " m"^3, ")"))
)nile_df$period <- ifelse(nile_df$year <= 1898, "1871--1898", "1899--1970")
period_summary <- nile_df |>
dplyr::group_by(period) |>
dplyr::summarise(n = dplyr::n(), mean_flow = mean(flow), sd_flow = sd(flow), .groups = "drop")
knitr::kable(period_summary, digits = 2, caption = "Nile flow before and after the apparent change point.")| period | n | mean_flow | sd_flow |
|---|---|---|---|
| 1871–1898 | 28 | 1097.75 | 135.00 |
| 1899–1970 | 72 | 849.97 | 124.78 |
ttest_nile <- t.test(flow ~ period, data = nile_df)
knitr::kable(
data.frame(
estimate_difference = diff(ttest_nile$estimate),
conf_low = ttest_nile$conf.int[1],
conf_high = ttest_nile$conf.int[2],
p_value = ttest_nile$p.value
),
digits = 4,
caption = "Two-sample comparison of mean flow across periods."
)| estimate_difference | conf_low | conf_high | p_value | |
|---|---|---|---|---|
| mean in group 1899–1970 | -247.7778 | 188.5048 | 307.0508 | 0 |
nile_lag <- nile_df |>
dplyr::mutate(lag1 = dplyr::lag(flow)) |>
stats::na.omit()
set.seed(707)
rand_train <- sample(seq_len(nrow(nile_lag)), size = round(0.7 * nrow(nile_lag)))
train_random <- nile_lag[rand_train, ]
test_random <- nile_lag[-rand_train, ]
time_cut <- sort(nile_lag$year)[round(0.7 * nrow(nile_lag))]
train_time <- nile_lag[nile_lag$year <= time_cut, ]
test_time <- nile_lag[nile_lag$year > time_cut, ]
fit_random <- rpart::rpart(flow ~ year + lag1, data = train_random)
fit_time <- rpart::rpart(flow ~ year + lag1, data = train_time)
validation_table <- data.frame(
validation_design = c("Random split", "Chronological split"),
test_RMSE = c(
rmse(test_random$flow, predict(fit_random, newdata = test_random)),
rmse(test_time$flow, predict(fit_time, newdata = test_time))
),
test_MAE = c(
mae(test_random$flow, predict(fit_random, newdata = test_random)),
mae(test_time$flow, predict(fit_time, newdata = test_time))
)
)
knitr::kable(validation_table, digits = 2, caption = "Random validation can be optimistic for time-dependent scientific data.")| validation_design | test_RMSE | test_MAE |
|---|---|---|
| Random split | 132.22 | 98.38 |
| Chronological split | 133.44 | 103.52 |
kf_nile <- kalman_local_level(as.numeric(Nile), q = 1500, r = 12000)
kf_nile$year <- as.integer(time(Nile))
ggplot2::ggplot(kf_nile, ggplot2::aes(year)) +
ggplot2::geom_point(ggplot2::aes(y = obs), alpha = 0.45) +
ggplot2::geom_ribbon(ggplot2::aes(ymin = lower, ymax = upper), alpha = 0.15) +
ggplot2::geom_line(ggplot2::aes(y = filtered), linewidth = 1) +
ggplot2::labs(
title = "Local-level Kalman filter for Nile flow",
subtitle = "A toy state-space smoother: hidden hydrological level plus noisy annual observations",
x = "year", y = expression(paste("flow (", 10^8, " m"^3, ")"))
)Interpretation. Time-series data need time-aware validation. Filtering gives a dynamic inference tool: the scientific state is updated sequentially as new measurements arrive.
The CO2 dataset concerns carbon dioxide uptake in grass
plants under different concentrations, treatments, and plant types. It
is useful for explaining regression, interaction, nonlinear
dose-response, and ML prediction.
data(CO2)
co2_df <- as.data.frame(CO2)
co2_df$log_conc <- log(co2_df$conc)
co2_lm <- lm(uptake ~ log_conc * Treatment + Type, data = co2_df)
coef_table <- data.frame(
term = names(coef(co2_lm)),
estimate = coef(co2_lm),
confint(co2_lm)
)
knitr::kable(coef_table, digits = 3, caption = "Regression effects for CO2 uptake.")| term | estimate | X2.5.. | X97.5.. | |
|---|---|---|---|---|
| (Intercept) | (Intercept) | -19.163 | -30.929 | -7.396 |
| log_conc | log_conc | 9.646 | 7.649 | 11.644 |
| Treatmentchilled | Treatmentchilled | 6.670 | -9.902 | 23.243 |
| TypeMississippi | TypeMississippi | -12.660 | -14.780 | -10.539 |
| log_conc:Treatmentchilled | log_conc:Treatmentchilled | -2.325 | -5.150 | 0.499 |
ggplot2::ggplot(co2_df, ggplot2::aes(conc, uptake, color = Treatment, shape = Type)) +
ggplot2::geom_point(size = 2) +
ggplot2::scale_x_log10() +
ggplot2::geom_smooth(method = "lm", formula = y ~ log(x), se = TRUE) +
ggplot2::labs(
title = "Plant physiology: CO2 uptake as a function of concentration",
subtitle = "Statistical regression estimates treatment and type effects; ML can capture nonlinear patterns",
x = "ambient CO2 concentration (log scale)", y = "CO2 uptake"
)set.seed(808)
tr_id <- sample(seq_len(nrow(co2_df)), size = round(0.75 * nrow(co2_df)))
co2_train <- co2_df[tr_id, ]
co2_test <- co2_df[-tr_id, ]
co2_lm2 <- lm(uptake ~ log_conc * Treatment + Type, data = co2_train)
co2_tree <- rpart::rpart(uptake ~ conc + Treatment + Type, data = co2_train)
co2_scores <- data.frame(
model = c("Interpretable statistical regression", "ML tree"),
test_RMSE = c(
rmse(co2_test$uptake, predict(co2_lm2, newdata = co2_test)),
rmse(co2_test$uptake, predict(co2_tree, newdata = co2_test))
)
)
knitr::kable(co2_scores, digits = 3, caption = "Predicting plant CO2 uptake.")| model | test_RMSE |
|---|---|
| Interpretable statistical regression | 3.696 |
| ML tree | 6.027 |
Interpretation. Regression gives interpretable treatment effects and confidence intervals. The tree can capture simple nonlinear thresholds and interactions. A biological scientist often needs both: explanation and prediction.
The volcano dataset gives topographic information for
Maunga Whau on a 10m by 10m grid. It is a toy example for spatial data,
surface modelling, residual maps, and geoscience visualization.
data(volcano)
volcano_df <- data.frame(
row = rep(seq_len(nrow(volcano)), times = ncol(volcano)),
col = rep(seq_len(ncol(volcano)), each = nrow(volcano)),
elevation = as.vector(volcano)
)
ggplot2::ggplot(volcano_df, ggplot2::aes(col, row, fill = elevation)) +
ggplot2::geom_raster() +
ggplot2::coord_equal() +
ggplot2::labs(
title = "Maunga Whau volcano topography",
subtitle = "A spatial scientific surface: every pixel has location and elevation",
x = "south-north grid", y = "east-west grid", fill = "elevation"
)set.seed(909)
vid <- sample(seq_len(nrow(volcano_df)), size = round(0.7 * nrow(volcano_df)))
vol_train <- volcano_df[vid, ]
vol_test <- volcano_df[-vid, ]
vol_lm <- lm(elevation ~ row + col + I(row^2) + I(col^2) + row:col, data = vol_train)
vol_tree <- rpart::rpart(elevation ~ row + col, data = vol_train, control = rpart::rpart.control(cp = 0.001))
vol_scores <- data.frame(
model = c("Quadratic statistical surface", "ML regression tree"),
test_RMSE = c(
rmse(vol_test$elevation, predict(vol_lm, newdata = vol_test)),
rmse(vol_test$elevation, predict(vol_tree, newdata = vol_test))
)
)
knitr::kable(vol_scores, digits = 3, caption = "Predicting elevation from grid location.")| model | test_RMSE |
|---|---|
| Quadratic statistical surface | 13.675 |
| ML regression tree | 6.666 |
volcano_df$pred_lm <- predict(vol_lm, newdata = volcano_df)
volcano_df$pred_tree <- predict(vol_tree, newdata = volcano_df)
volcano_df$resid_lm <- volcano_df$elevation - volcano_df$pred_lm
volcano_df$resid_tree <- volcano_df$elevation - volcano_df$pred_tree
resid_long <- tidyr::pivot_longer(
volcano_df,
cols = c(resid_lm, resid_tree),
names_to = "model",
values_to = "residual"
)
ggplot2::ggplot(resid_long, ggplot2::aes(col, row, fill = residual)) +
ggplot2::geom_raster() +
ggplot2::coord_equal() +
ggplot2::facet_wrap(~model) +
ggplot2::labs(
title = "Residual maps: what the models fail to explain",
subtitle = "Residual structure is scientific information, not merely error",
x = "south-north grid", y = "east-west grid", fill = "residual"
)Interpretation. Spatial residual maps are a very useful diagnostic. If residuals show structure, the model is missing spatial geometry. ML may reduce prediction error, but a statistical residual map explains where the model is scientifically incomplete.
The rock dataset contains measurements on 48 petroleum
rock samples. Variables include pore area, perimeter, shape, and
permeability. This is a good example of small-data scientific inference:
ML may be unstable, and uncertainty matters.
data(rock)
rock_df <- rock |>
dplyr::mutate(
log_perm = log(perm),
log_area = log(area),
log_peri = log(peri)
)
rock_lm <- lm(log_perm ~ log_area + log_peri + shape, data = rock_df)
rock_tree <- rpart::rpart(log_perm ~ log_area + log_peri + shape, data = rock_df,
control = rpart::rpart.control(minsplit = 10, cp = 0.01))
knitr::kable(
data.frame(term = names(coef(rock_lm)), estimate = coef(rock_lm), confint(rock_lm)),
digits = 3,
caption = "Small-data petrophysics: regression effects for log permeability."
)| term | estimate | X2.5.. | X97.5.. | |
|---|---|---|---|---|
| (Intercept) | (Intercept) | 4.848 | 0.125 | 9.572 |
| log_area | log_area | 3.297 | 2.250 | 4.344 |
| log_peri | log_peri | -3.747 | -4.634 | -2.860 |
| shape | shape | 0.907 | -2.836 | 4.650 |
ggplot2::ggplot(rock_df, ggplot2::aes(log_area, log_perm, size = shape)) +
ggplot2::geom_point(alpha = 0.65) +
ggplot2::geom_smooth(method = "lm", se = TRUE) +
ggplot2::labs(
title = "Petroleum rock samples: pore area and permeability",
subtitle = "Small data: uncertainty and scientific interpretability matter strongly",
x = "log pore area", y = "log permeability", size = "shape"
)loocv_rmse_lm <- function(data) {
pred <- numeric(nrow(data))
for (i in seq_len(nrow(data))) {
fit <- lm(log_perm ~ log_area + log_peri + shape, data = data[-i, ])
pred[i] <- predict(fit, newdata = data[i, ])
}
rmse(data$log_perm, pred)
}
loocv_rmse_tree <- function(data) {
pred <- numeric(nrow(data))
for (i in seq_len(nrow(data))) {
fit <- rpart::rpart(log_perm ~ log_area + log_peri + shape, data = data[-i, ],
control = rpart::rpart.control(minsplit = 10, cp = 0.01))
pred[i] <- predict(fit, newdata = data[i, ])
}
rmse(data$log_perm, pred)
}
rock_cv <- data.frame(
model = c("Linear small-data inference model", "ML tree"),
LOOCV_RMSE = c(loocv_rmse_lm(rock_df), loocv_rmse_tree(rock_df))
)
knitr::kable(rock_cv, digits = 3, caption = "Leave-one-out cross-validation in a small scientific dataset.")| model | LOOCV_RMSE |
|---|---|
| Linear small-data inference model | 0.948 |
| ML tree | 1.091 |
Interpretation. In small scientific datasets, a transparent statistical model may be preferable to a highly flexible learner. ML is not wrong, but flexibility must be paid for by data volume and validation strength.
| Situation | Statistical danger | ML danger | Good data-science response |
|---|---|---|---|
| Nonlinear relation | underfitting by linear model | overfitting if too flexible | compare interpretable and flexible models |
| Time series | false independent-sample uncertainty | random-split leakage | chronological validation, stochastic-process models |
| Hidden state | ignoring measurement noise | treating observations as truth | state-space modelling and filtering |
| Small sample | unstable p-values if assumptions fail | unstable flexible learners | regularization, resampling, transparent modelling |
| Extrapolation | wrong parametric law | no extrapolation mechanism | scientific structure plus validation |
| Spatial surface | residual spatial dependence ignored | good prediction but little explanation | residual maps, spatial models, uncertainty |
Statistical inference and ML are not enemies. They are two languages for scientific data science.
For basic sciences, the most powerful workflow is not statistics versus ML, but:
\[ \text{scientific structure} + \text{statistical inference} + \text{machine learning} + \text{uncertainty quantification}. \]
faithful,
Nile, volcano, rock, and
CO2.