This short tutorial is designed for students who may know regression or machine learning, but may not yet see clearly what Bayesian inference adds.
The main message is not “Bayes versus ML”. The message is:
Machine learning is strong at prediction; Bayesian inference is strong at uncertainty, small-data reasoning, regularisation, and scientific interpretability. In natural-science applications, the most useful workflow often combines both.
We shall use small examples from natural-science style datasets already available in R:
datasets::airquality.datasets::iris.MASS::Animals.survival::lung.The examples are intentionally small, so that the statistical ideas are visible.
A classical estimator is usually a function of data:
\[ \widehat\theta = T(Y_1,\ldots,Y_n). \]
The unknown parameter \(\theta\) is treated as fixed, and uncertainty is described through repeated-sampling behaviour of \(\widehat\theta\).
Bayesian inference treats the unknown quantity as uncertain and updates belief using Bayes’ rule:
\[ \pi(\theta \mid y) \propto L(\theta;y)\,\pi(\theta), \]
where
A Bayesian answer is often a distribution, not only one number. From the posterior we can compute:
Many ML methods optimise predictive performance:
\[ \widehat f = \arg\min_f \sum_{i=1}^n \ell(y_i,f(x_i)). \]
This is extremely powerful when the main aim is prediction. But in science, we often also need:
That is where Bayesian thinking becomes complementary.
rmse <- function(y, yhat) sqrt(mean((y - yhat)^2, na.rm = TRUE))
train_test_split <- function(n, train_prop = 0.7) {
sample(seq_len(n), size = floor(train_prop * n))
}
# Conjugate Bayesian Gaussian linear regression:
# y | beta, sigma2 ~ N(X beta, sigma2 I)
# beta | sigma2 ~ N(beta0, sigma2 V0)
# sigma2 ~ Inv-Gamma(a0, b0)
# Here Inv-Gamma is parameterised through 1/sigma2 ~ Gamma(shape=a, rate=b).
bayes_lm_nig <- function(X, y, beta0 = NULL, V0 = NULL, a0 = 2, b0 = 1) {
X <- as.matrix(X)
y <- as.numeric(y)
n <- nrow(X)
p <- ncol(X)
if (is.null(beta0)) beta0 <- rep(0, p)
if (is.null(V0)) V0 <- diag(100, p)
V0_inv <- solve(V0)
Vn <- solve(V0_inv + crossprod(X))
betan <- Vn %*% (V0_inv %*% beta0 + crossprod(X, y))
an <- a0 + n / 2
bn <- as.numeric(b0 + 0.5 * (crossprod(y) + t(beta0) %*% V0_inv %*% beta0 - t(betan) %*% solve(Vn) %*% betan))
list(beta_mean = as.numeric(betan), Vn = Vn, an = an, bn = bn, X = X, y = y)
}
draw_bayes_lm <- function(fit, Xnew, S = 4000, include_noise = TRUE) {
Xnew <- as.matrix(Xnew)
p <- length(fit$beta_mean)
sigma2 <- 1 / rgamma(S, shape = fit$an, rate = fit$bn)
beta_draws <- matrix(NA_real_, nrow = S, ncol = p)
for (s in seq_len(S)) {
beta_draws[s, ] <- MASS::mvrnorm(1, mu = fit$beta_mean, Sigma = sigma2[s] * fit$Vn)
}
mu_draws <- beta_draws %*% t(Xnew)
if (include_noise) {
y_draws <- mu_draws + matrix(rnorm(length(mu_draws), sd = sqrt(rep(sigma2, ncol(Xnew)))),
nrow = S, ncol = nrow(Xnew))
return(y_draws)
}
mu_draws
}
summarise_draws <- function(draw_mat) {
data.frame(
mean = colMeans(draw_mat),
lwr = apply(draw_mat, 2, quantile, 0.025),
upr = apply(draw_mat, 2, quantile, 0.975)
)
}
coverage95 <- function(y, lwr, upr) mean(y >= lwr & y <= upr, na.rm = TRUE)
We simulate a small nonlinear relationship:
\[ y = \sin(2x)+0.5x+\epsilon. \]
A simple Bayesian linear regression is deliberately misspecified here because the true pattern is nonlinear. A decision tree can adapt to nonlinearity, but does not automatically give a calibrated posterior uncertainty interval. Then we combine them by using the tree prediction as an input to a Bayesian calibration model.
set.seed(1)
n <- 80
sim <- data.frame(
x = runif(n, -2.5, 2.5)
)
sim$truth <- sin(2 * sim$x) + 0.5 * sim$x
sim$y <- sim$truth + rnorm(n, sd = 0.45)
idx <- train_test_split(nrow(sim), 0.7)
train <- sim[idx, ]
test <- sim[-idx, ]
p <- ggplot(sim, aes(x, y)) +
geom_point(alpha = 0.75) +
stat_function(fun = function(z) sin(2*z) + 0.5*z, linewidth = 1) +
labs(title = "Simulated nonlinear natural-science style signal",
subtitle = "Black curve = true mean function",
x = "x", y = "response") +
theme_minimal()
p
Xtr <- model.matrix(~ x, data = train)
Xte <- model.matrix(~ x, data = test)
fit_bayes_lin <- bayes_lm_nig(Xtr, train$y)
draw_lin <- draw_bayes_lm(fit_bayes_lin, Xte, S = 3000, include_noise = TRUE)
pred_lin <- summarise_draws(draw_lin)
rmse_lin <- rmse(test$y, pred_lin$mean)
cov_lin <- coverage95(test$y, pred_lin$lwr, pred_lin$upr)
c(RMSE = rmse_lin, Coverage95 = cov_lin)
## RMSE Coverage95
## 0.9092169 0.9583333
tree_sim <- rpart::rpart(y ~ x, data = train, method = "anova",
control = rpart.control(cp = 0.005, minsplit = 8))
pred_tree <- predict(tree_sim, newdata = test)
rmse_tree <- rmse(test$y, pred_tree)
rmse_tree
## [1] 0.4877032
Here the ML tree discovers nonlinear structure. We then fit a Bayesian calibration layer:
\[ y_i = \alpha + \gamma\,\widehat f_{ML}(x_i)+e_i. \]
This is a simple version of Bayesian post-processing of an ML model.
train$tree_hat <- predict(tree_sim, newdata = train)
test$tree_hat <- predict(tree_sim, newdata = test)
Xtr_h <- model.matrix(~ tree_hat, data = train)
Xte_h <- model.matrix(~ tree_hat, data = test)
fit_hybrid <- bayes_lm_nig(Xtr_h, train$y)
draw_hybrid <- draw_bayes_lm(fit_hybrid, Xte_h, S = 3000, include_noise = TRUE)
pred_hybrid <- summarise_draws(draw_hybrid)
rmse_hybrid <- rmse(test$y, pred_hybrid$mean)
cov_hybrid <- coverage95(test$y, pred_hybrid$lwr, pred_hybrid$upr)
data.frame(
Model = c("Bayesian linear", "ML tree", "ML tree + Bayesian calibration"),
RMSE = c(rmse_lin, rmse_tree, rmse_hybrid),
Coverage95 = c(cov_lin, NA, cov_hybrid)
)
## Model RMSE Coverage95
## 1 Bayesian linear 0.9092169 0.9583333
## 2 ML tree 0.4877032 NA
## 3 ML tree + Bayesian calibration 0.4859872 0.8750000
plot_df <- test %>%
mutate(
bayes_mean = pred_lin$mean,
bayes_lwr = pred_lin$lwr,
bayes_upr = pred_lin$upr,
tree_pred = pred_tree,
hybrid_mean = pred_hybrid$mean,
hybrid_lwr = pred_hybrid$lwr,
hybrid_upr = pred_hybrid$upr
) %>%
arrange(x)
ggplot(plot_df, aes(x, y)) +
geom_point(size = 2) +
geom_line(aes(y = bayes_mean), linewidth = 1, linetype = 2) +
geom_ribbon(aes(ymin = bayes_lwr, ymax = bayes_upr), alpha = 0.15) +
geom_line(aes(y = hybrid_mean), linewidth = 1) +
geom_ribbon(aes(ymin = hybrid_lwr, ymax = hybrid_upr), alpha = 0.15) +
labs(title = "Bayesian linear vs ML+Bayesian calibration",
subtitle = "Dashed = Bayesian linear; solid = Bayesian calibration of tree predictions",
x = "x", y = "y") +
theme_minimal()
Lesson: ML captures flexible pattern; Bayes reports uncertainty and calibrates predictions. Together, we can often obtain both adaptability and uncertainty.
airqualityThe airquality dataset contains daily air-quality
measurements in New York, May–September 1973. We model ozone using
temperature, wind, and solar radiation.
data("airquality")
air <- airquality %>%
dplyr::select(Ozone, Solar.R, Wind, Temp, Month) %>%
tidyr::drop_na()
summary(air)
## Ozone Solar.R Wind Temp
## Min. : 1.0 Min. : 7.0 Min. : 2.30 Min. :57.00
## 1st Qu.: 18.0 1st Qu.:113.5 1st Qu.: 7.40 1st Qu.:71.00
## Median : 31.0 Median :207.0 Median : 9.70 Median :79.00
## Mean : 42.1 Mean :184.8 Mean : 9.94 Mean :77.79
## 3rd Qu.: 62.0 3rd Qu.:255.5 3rd Qu.:11.50 3rd Qu.:84.50
## Max. :168.0 Max. :334.0 Max. :20.70 Max. :97.00
## Month
## Min. :5.000
## 1st Qu.:6.000
## Median :7.000
## Mean :7.216
## 3rd Qu.:9.000
## Max. :9.000
ggplot(air, aes(Temp, Ozone, colour = Wind)) +
geom_point(size = 2) +
scale_colour_viridis_c() +
labs(title = "Environmental data: ozone, temperature and wind",
x = "Temperature (F)", y = "Ozone (ppb)") +
theme_minimal()
set.seed(2)
idx <- train_test_split(nrow(air), 0.72)
train <- air[idx, ]
test <- air[-idx, ]
# Standardise predictors using training data only.
vars <- c("Solar.R", "Wind", "Temp")
mu <- sapply(train[vars], mean)
sdval <- sapply(train[vars], sd)
scale_df <- function(dat) {
out <- dat
out[vars] <- sweep(sweep(out[vars], 2, mu, "-"), 2, sdval, "/")
out
}
train_s <- scale_df(train)
test_s <- scale_df(test)
Xtr <- model.matrix(~ Solar.R + Wind + Temp, data = train_s)
Xte <- model.matrix(~ Solar.R + Wind + Temp, data = test_s)
fit_air_bayes <- bayes_lm_nig(Xtr, train_s$Ozone)
draw_air <- draw_bayes_lm(fit_air_bayes, Xte, S = 4000, include_noise = TRUE)
pred_air_bayes <- summarise_draws(draw_air)
rmse_air_bayes <- rmse(test_s$Ozone, pred_air_bayes$mean)
cov_air_bayes <- coverage95(test_s$Ozone, pred_air_bayes$lwr, pred_air_bayes$upr)
# Posterior coefficient summaries
coef_draws <- draw_bayes_lm(fit_air_bayes, Xnew = diag(ncol(Xtr)), S = 1, include_noise = FALSE)
# Better: draw beta directly for coefficient summaries
S <- 5000
sigma2 <- 1 / rgamma(S, fit_air_bayes$an, rate = fit_air_bayes$bn)
beta_draws <- matrix(NA_real_, S, ncol(Xtr))
for (s in seq_len(S)) {
beta_draws[s, ] <- MASS::mvrnorm(1, fit_air_bayes$beta_mean, sigma2[s] * fit_air_bayes$Vn)
}
coef_tab <- data.frame(
Term = colnames(Xtr),
Median = apply(beta_draws, 2, median),
L95 = apply(beta_draws, 2, quantile, 0.025),
U95 = apply(beta_draws, 2, quantile, 0.975),
Prob_Positive = colMeans(beta_draws > 0)
)
coef_tab
## Term Median L95 U95 Prob_Positive
## 1 (Intercept) 39.864623 36.2417994 43.547716 1.0000
## 2 Solar.R 3.535845 -0.3419495 7.211755 0.9622
## 3 Wind -9.672489 -14.1211400 -5.263183 0.0000
## 4 Temp 17.718192 13.0640154 22.225853 1.0000
tree_air <- rpart::rpart(Ozone ~ Solar.R + Wind + Temp, data = train_s,
method = "anova",
control = rpart.control(cp = 0.01, minsplit = 10))
pred_air_tree <- predict(tree_air, newdata = test_s)
rmse_air_tree <- rmse(test_s$Ozone, pred_air_tree)
train_s$tree_hat <- predict(tree_air, newdata = train_s)
test_s$tree_hat <- predict(tree_air, newdata = test_s)
Xtr_h <- model.matrix(~ tree_hat, data = train_s)
Xte_h <- model.matrix(~ tree_hat, data = test_s)
fit_air_hybrid <- bayes_lm_nig(Xtr_h, train_s$Ozone)
draw_air_hybrid <- draw_bayes_lm(fit_air_hybrid, Xte_h, S = 4000, include_noise = TRUE)
pred_air_hybrid <- summarise_draws(draw_air_hybrid)
rmse_air_hybrid <- rmse(test_s$Ozone, pred_air_hybrid$mean)
cov_air_hybrid <- coverage95(test_s$Ozone, pred_air_hybrid$lwr, pred_air_hybrid$upr)
score_air <- data.frame(
Model = c("Bayesian linear", "ML tree", "ML tree + Bayesian calibration"),
RMSE = c(rmse_air_bayes, rmse_air_tree, rmse_air_hybrid),
Coverage95 = c(cov_air_bayes, NA, cov_air_hybrid)
)
score_air
## Model RMSE Coverage95
## 1 Bayesian linear 28.71827 0.8125
## 2 ML tree 31.44423 NA
## 3 ML tree + Bayesian calibration 31.45280 0.6250
pred_df <- test_s %>%
mutate(
Bayesian = pred_air_bayes$mean,
Hybrid = pred_air_hybrid$mean,
Hybrid_L = pred_air_hybrid$lwr,
Hybrid_U = pred_air_hybrid$upr
) %>%
arrange(Ozone)
ggplot(pred_df, aes(Ozone, Hybrid)) +
geom_point(size = 2) +
geom_errorbar(aes(ymin = Hybrid_L, ymax = Hybrid_U), alpha = 0.25) +
geom_abline(slope = 1, intercept = 0, linetype = 2) +
labs(title = "Ozone prediction: ML tree with Bayesian uncertainty calibration",
x = "Observed ozone", y = "Predicted ozone") +
theme_minimal()
Scientific interpretation: a tree may capture nonlinear environmental effects, while Bayesian calibration gives a predictive interval. For policy or monitoring, an interval is often more useful than a single point forecast.
irisThe iris data contain measurements for 150 flowers from three species. To make the problem nontrivial, we classify only versicolor vs virginica.
data("iris")
iris2 <- iris %>%
filter(Species != "setosa") %>%
droplevels()
summary(iris2)
## Sepal.Length Sepal.Width Petal.Length Petal.Width
## Min. :4.900 Min. :2.000 Min. :3.000 Min. :1.000
## 1st Qu.:5.800 1st Qu.:2.700 1st Qu.:4.375 1st Qu.:1.300
## Median :6.300 Median :2.900 Median :4.900 Median :1.600
## Mean :6.262 Mean :2.872 Mean :4.906 Mean :1.676
## 3rd Qu.:6.700 3rd Qu.:3.025 3rd Qu.:5.525 3rd Qu.:2.000
## Max. :7.900 Max. :3.800 Max. :6.900 Max. :2.500
## Species
## versicolor:50
## virginica :50
##
##
##
##
set.seed(3)
idx <- train_test_split(nrow(iris2), 0.7)
train <- iris2[idx, ]
test <- iris2[-idx, ]
tree_iris <- rpart::rpart(Species ~ Sepal.Length + Sepal.Width + Petal.Length + Petal.Width,
data = train,
method = "class",
control = rpart.control(cp = 0.01, minsplit = 8))
prob_tree <- predict(tree_iris, newdata = test, type = "prob")
pred_tree <- colnames(prob_tree)[max.col(prob_tree)]
acc_tree <- mean(pred_tree == test$Species)
acc_tree
## [1] 0.9333333
This is a small Bayesian classifier. For each class and each feature, we use a Normal–Inverse-Gamma model, yielding a Student-\(t\) posterior predictive density. Class probabilities are combined using Bayes’ rule.
fit_one_feature <- function(x, m0 = 0, k0 = 0.01, a0 = 2, b0 = 1) {
x <- as.numeric(x)
n <- length(x)
xbar <- mean(x)
ss <- sum((x - xbar)^2)
kn <- k0 + n
mn <- (k0 * m0 + n * xbar) / kn
an <- a0 + n / 2
bn <- b0 + 0.5 * ss + (k0 * n * (xbar - m0)^2) / (2 * kn)
list(mn = mn, kn = kn, an = an, bn = bn)
}
log_pred_t <- function(xnew, fit) {
df <- 2 * fit$an
scale <- sqrt(fit$bn * (fit$kn + 1) / (fit$an * fit$kn))
dt((xnew - fit$mn) / scale, df = df, log = TRUE) - log(scale)
}
bayes_nb_fit <- function(train, yvar, xvars) {
y <- train[[yvar]]
classes <- levels(y)
alpha <- rep(1, length(classes))
names(alpha) <- classes
class_counts <- table(y)
class_prior <- (as.numeric(class_counts[classes]) + alpha) /
(sum(class_counts) + sum(alpha))
names(class_prior) <- classes
fits <- list()
for (cl in classes) {
fits[[cl]] <- lapply(xvars, function(v) fit_one_feature(train[train[[yvar]] == cl, v]))
names(fits[[cl]]) <- xvars
}
list(classes = classes, prior = class_prior, fits = fits, xvars = xvars)
}
bayes_nb_predict <- function(object, newdata) {
logp <- matrix(NA_real_, nrow = nrow(newdata), ncol = length(object$classes))
colnames(logp) <- object$classes
for (cl in object$classes) {
lp <- rep(log(object$prior[cl]), nrow(newdata))
for (v in object$xvars) {
lp <- lp + log_pred_t(newdata[[v]], object$fits[[cl]][[v]])
}
logp[, cl] <- lp
}
# stable softmax
logp_centered <- logp - apply(logp, 1, max)
prob <- exp(logp_centered) / rowSums(exp(logp_centered))
as.data.frame(prob)
}
xvars <- c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width")
bnb <- bayes_nb_fit(train, "Species", xvars)
prob_bayes <- bayes_nb_predict(bnb, test)
pred_bayes <- colnames(prob_bayes)[max.col(prob_bayes)]
acc_bayes <- mean(pred_bayes == test$Species)
# Hybrid ensemble: average probabilities from ML tree and Bayesian classifier
common_cols <- intersect(colnames(prob_tree), colnames(prob_bayes))
prob_hybrid <- 0.5 * prob_tree[, common_cols] + 0.5 * as.matrix(prob_bayes[, common_cols])
pred_hybrid <- common_cols[max.col(prob_hybrid)]
acc_hybrid <- mean(pred_hybrid == test$Species)
data.frame(
Model = c("ML decision tree", "Bayesian Gaussian classifier", "Average ML + Bayesian probabilities"),
Accuracy = c(acc_tree, acc_bayes, acc_hybrid),
Mean_Max_Probability = c(mean(apply(prob_tree, 1, max)),
mean(apply(prob_bayes, 1, max)),
mean(apply(prob_hybrid, 1, max)))
)
## Model Accuracy Mean_Max_Probability
## 1 ML decision tree 0.9333333 0.9916667
## 2 Bayesian Gaussian classifier 0.9000000 0.9166456
## 3 Average ML + Bayesian probabilities 0.9333333 0.9526973
iris_pred <- test %>%
mutate(
Bayes_MaxProb = apply(prob_bayes, 1, max),
Bayes_Pred = pred_bayes,
Correct = Bayes_Pred == Species
)
ggplot(iris_pred, aes(Petal.Length, Petal.Width, colour = Bayes_MaxProb, shape = Correct)) +
geom_point(size = 3) +
scale_colour_viridis_c(limits = c(0.5, 1)) +
labs(title = "Bayesian classifier uncertainty on iris species",
subtitle = "Lower maximum posterior class probability indicates uncertainty",
x = "Petal length", y = "Petal width", colour = "Max posterior\nclass probability") +
theme_minimal()
Lesson: ML gives a decision boundary; Bayesian classification gives probability and uncertainty. The hybrid probability average can be a simple way to stabilise predictions.
MASS::AnimalsAllometry asks how biological quantities scale with body size. Here we study the relationship between body weight and brain weight in animals. With only a small number of species, a Bayesian regression is valuable because uncertainty in the allometric exponent matters.
data("Animals", package = "MASS")
animals <- MASS::Animals %>%
tibble::rownames_to_column("Animal") %>%
mutate(log_body = log(body), log_brain = log(brain))
head(animals)
## Animal body brain log_body log_brain
## 1 Mountain beaver 1.35 8.1 0.30010459 2.091864
## 2 Cow 465.00 423.0 6.14203741 6.047372
## 3 Grey wolf 36.33 119.5 3.59264385 4.783316
## 4 Goat 27.66 115.0 3.31998733 4.744932
## 5 Guinea pig 1.04 5.5 0.03922071 1.704748
## 6 Dipliodocus 11700.00 50.0 9.36734412 3.912023
ggplot(animals, aes(log_body, log_brain, label = Animal)) +
geom_point(size = 2) +
geom_smooth(method = "lm", se = FALSE) +
labs(title = "Zoological allometry: brain weight vs body weight",
x = "log body weight", y = "log brain weight") +
theme_minimal()
X <- model.matrix(~ log_body, data = animals)
fit_animals <- bayes_lm_nig(X, animals$log_brain, a0 = 2, b0 = 1)
S <- 6000
sigma2 <- 1 / rgamma(S, fit_animals$an, rate = fit_animals$bn)
beta_draws <- matrix(NA_real_, S, ncol(X))
for (s in seq_len(S)) {
beta_draws[s, ] <- MASS::mvrnorm(1, fit_animals$beta_mean, sigma2[s] * fit_animals$Vn)
}
colnames(beta_draws) <- colnames(X)
allometry_tab <- data.frame(
Parameter = colnames(beta_draws),
Median = apply(beta_draws, 2, median),
L95 = apply(beta_draws, 2, quantile, 0.025),
U95 = apply(beta_draws, 2, quantile, 0.975),
Prob_Positive = colMeans(beta_draws > 0)
)
allometry_tab
## Parameter Median L95 U95 Prob_Positive
## (Intercept) (Intercept) 2.557552 1.7844757 3.3322046 1
## log_body log_body 0.497074 0.3482656 0.6420763 1
beta_df <- data.frame(slope = beta_draws[, "log_body"])
ggplot(beta_df, aes(slope)) +
geom_density(fill = "steelblue", alpha = 0.25) +
geom_vline(xintercept = median(beta_df$slope), linetype = 2) +
labs(title = "Posterior uncertainty in the allometric exponent",
x = "Slope of log(brain) on log(body)", y = "Posterior density") +
theme_minimal()
Lesson: when the scientific target is an interpretable exponent, a Bayesian posterior is more informative than only a fitted line.
survival::lungSurvival data are different from ordinary regression data because some patients are censored. The event time is not always observed exactly. We use a simple exponential survival model with censoring to demonstrate Bayesian small-data inference.
For an exponential event time with rate \(\lambda\), censored survival likelihood contributes
\[ L(\lambda) \propto \lambda^d \exp(-\lambda T), \]
where \(d\) is the number of observed events and \(T\) is total observed follow-up time. With prior
\[ \lambda \sim \mathrm{Gamma}(a_0,b_0), \]
the posterior is
\[ \lambda\mid data \sim \mathrm{Gamma}(a_0+d, b_0+T). \]
data("lung", package = "survival")
lung2 <- survival::lung %>%
mutate(
event = ifelse(status == 2, 1, 0),
sex_label = ifelse(sex == 1, "Male", "Female")
) %>%
filter(!is.na(time), !is.na(event), !is.na(sex_label))
table(lung2$sex_label, lung2$event)
##
## 0 1
## Female 37 53
## Male 26 112
km <- survival::survfit(survival::Surv(time, event) ~ sex_label, data = lung2)
plot(km, col = c("firebrick", "navy"), lwd = 2,
xlab = "Days", ylab = "Survival probability",
main = "Kaplan--Meier curves by sex")
legend("topright", legend = names(km$strata), col = c("firebrick", "navy"), lwd = 2, bty = "n")
bayes_exp_rate <- function(time, event, a0 = 0.01, b0 = 0.01, S = 10000) {
d <- sum(event == 1)
Ttot <- sum(time)
shape <- a0 + d
rate <- b0 + Ttot
lambda <- rgamma(S, shape = shape, rate = rate)
med_surv <- log(2) / lambda
data.frame(lambda = lambda, median_survival = med_surv)
}
post_male <- bayes_exp_rate(lung2$time[lung2$sex_label == "Male"],
lung2$event[lung2$sex_label == "Male"])
post_female <- bayes_exp_rate(lung2$time[lung2$sex_label == "Female"],
lung2$event[lung2$sex_label == "Female"])
post_surv <- bind_rows(
post_male %>% mutate(Group = "Male"),
post_female %>% mutate(Group = "Female")
)
surv_summary <- post_surv %>%
group_by(Group) %>%
summarise(
median_rate = median(lambda),
L95_rate = quantile(lambda, 0.025),
U95_rate = quantile(lambda, 0.975),
median_survival_days = median(median_survival),
L95_median_survival = quantile(median_survival, 0.025),
U95_median_survival = quantile(median_survival, 0.975),
.groups = "drop"
)
surv_summary
## # A tibble: 2 × 7
## Group median_rate L95_rate U95_rate median_survival_days L95_median_survival
## <chr> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 Female 0.00173 0.00129 0.00223 401. 311.
## 2 Male 0.00285 0.00236 0.00341 243. 203.
## # ℹ 1 more variable: U95_median_survival <dbl>
ggplot(post_surv, aes(median_survival, fill = Group)) +
geom_density(alpha = 0.35) +
labs(title = "Bayesian posterior for median survival under exponential model",
x = "Median survival in days", y = "Posterior density") +
theme_minimal()
# A direct Bayesian probability question:
# What is the posterior probability that median survival is larger for females than males?
prob_female_better <- mean(post_female$median_survival > post_male$median_survival)
prob_female_better
## [1] 0.9988
Lesson: Bayesian survival inference lets us answer
direct probability questions such as:
“What is the posterior probability that one group has larger median
survival than another?”
| Question | ML/AI answer | Bayesian answer | Combined answer |
|---|---|---|---|
| Is prediction possible? | Often yes, using flexible learners. | Yes, especially with probabilistic models. | Use ML predictors inside Bayesian calibration. |
| How uncertain is the answer? | Not always automatic. | Natural through posterior/predictive intervals. | Bayesian post-processing of ML outputs. |
| What happens in small data? | Risk of overfitting. | Priors regularise and stabilise. | ML features + Bayesian shrinkage. |
| Is the model interpretable? | Depends on the learner. | Often clearer through parameters. | ML for pattern, Bayes for explanation. |
| Natural-science decision? | Point prediction. | Uncertainty-aware decision. | Predictive + calibrated + interpretable. |
Bayesian inference and AI/ML should not be presented as enemies. In natural-science data analysis:
For scientific students, the key habit is:
Do not ask only: what is the prediction? Also ask: how uncertain is it, why should I believe it, and what scientific decision depends on it?
citation()
citation("MASS")
citation("survival")
citation("rpart")
citation("ggplot2")
Useful references:
Bayes_ML_Natural_Science_RPubs.Rmd.