# ggplot2 is used for all plots. If missing, this line installs it once.
if (!requireNamespace("ggplot2", quietly = TRUE)) {
install.packages("ggplot2", repos = "https://cloud.r-project.org")
}
library(ggplot2)By the end of this handout, you will be able to:
Machine Learning is teaching a computer to find patterns in data, so it can make predictions on new, unseen data. The workflow is the step-by-step recipe we always follow:
| Step | What happens | Civil engineering example |
|---|---|---|
| 1. Ask a question | Define the prediction goal | “What will be the 28-day strength of this concrete mix?” |
| 2. Collect data | Gather measurements | Cube-crushing test records: cement, water, age, strength |
| 3. Prepare data | Clean, scale, encode, engineer features | Fix missing sensor readings, convert soil types to numbers |
| 4. Split data | Separate into training and testing sets | Mock exam vs. final exam |
| 5. Train the model | Let the algorithm learn patterns | Model learns “more cement + proper curing → stronger” |
| 6. Evaluate | Check accuracy on unseen test data | RMSE of strength predictions |
| 7. Use & improve | Deploy, monitor, retrain | Live strength predictor on the batching plant |
Why? Real sensor logs and lab registers always have gaps (sensor failure), repeats (double entry), and errors (a negative curing age!). Most ML algorithms cannot handle missing values at all.
set.seed(123)
cement <- round(runif(12, 250, 450), 1) # kg/m3
water <- round(runif(12, 150, 220), 1) # litres/m3
age <- sample(c(7, 14, 28, 90), 12, replace = TRUE) # curing days
strength <- round(20 + 0.08 * cement - 0.10 * water + 0.15 * age + rnorm(12, 0, 3), 1) # MPa
concrete <- data.frame(cement, water, age, strength)
# --- Inject real-world problems, like a messy lab register ---
concrete$cement[3] <- NA # sensor failed
concrete$water[6] <- NA # reading lost
concrete$strength[9] <- NA # cube crushed wrongly
concrete$age[4] <- -5 # impossible: curing age can't be negative
concrete <- rbind(concrete, concrete[2, ]) # same row typed twice
concrete## cement water age strength
## 1 307.5 197.4 7 28.0
## 2 407.7 190.1 28 36.4
## 3 NA 157.2 90 41.1
## 4 426.6 213.0 -5 34.3
## 5 438.1 167.2 28 39.4
## 6 259.1 NA 14 25.4
## 7 355.6 173.0 7 30.3
## 8 428.5 216.8 14 29.6
## 9 360.3 212.3 28 NA
## 10 341.3 198.5 90 41.4
## 11 441.4 194.8 14 34.5
## 12 340.7 219.6 7 30.1
## 21 407.7 190.1 28 36.4
Now the cleaning steps — count the damage, remove duplicates, fix impossible values, then handle gaps:
## cement water age strength
## 1 1 0 1
# 2) Remove duplicate rows
concrete <- concrete[!duplicated(concrete), ]
# 3) Impossible value -> convert to NA (we don't trust it)
concrete$age[concrete$age < 0] <- NA
# 4) Strategy A: DROP incomplete rows (simple, loses data)
concrete_dropped <- na.omit(concrete)
# 5) Strategy B: IMPUTE - fill gaps with the median (keeps all rows)
concrete_imputed <- concrete
concrete_imputed$cement[is.na(concrete_imputed$cement)] <- median(concrete_imputed$cement, na.rm = TRUE)
concrete_imputed$water[is.na(concrete_imputed$water)] <- median(concrete_imputed$water, na.rm = TRUE)
concrete_imputed$strength[is.na(concrete_imputed$strength)] <- median(concrete_imputed$strength, na.rm = TRUE)
concrete_imputed$age[is.na(concrete_imputed$age)] <- median(concrete_imputed$age, na.rm = TRUE)
c(
"Before cleaning" = 13, "After dropping NA" = nrow(concrete_dropped),
"After imputing NA" = nrow(concrete_imputed)
)## Before cleaning After dropping NA After imputing NA
## 13 8 12
## cement water age strength
## 1 307.5 197.4 7 28.0
## 2 407.7 190.1 28 36.4
## 3 360.3 157.2 90 41.1
## 4 426.6 213.0 14 34.3
## 5 438.1 167.2 28 39.4
## 6 259.1 197.4 14 25.4
Why? Cement ranges from 200–500 while water ranges from 140–230. A distance-based algorithm (KNN, K-means — coming in Unit 4) would think cement is “much more important” just because its numbers are bigger. Scaling puts every feature on a fair, comparable range.
Two standard methods:
\[x' = \frac{x - x_{min}}{x_{max} - x_{min}}\]
\[z = \frac{x - \mu}{\sigma}\]
min_max <- function(x) (x - min(x)) / (max(x) - min(x))
concrete_imputed$cement_minmax <- round(min_max(concrete_imputed$cement), 3)
concrete_imputed$water_minmax <- round(min_max(concrete_imputed$water), 3)
concrete_imputed$strength_z <- round(as.numeric(scale(concrete_imputed$strength)), 3)
head(concrete_imputed[, c("cement", "cement_minmax", "water", "water_minmax", "strength", "strength_z")])## cement cement_minmax water water_minmax strength strength_z
## 1 307.5 0.265 197.4 0.644 28.0 -1.102
## 2 407.7 0.815 190.1 0.527 36.4 0.512
## 3 360.3 0.555 157.2 0.000 41.1 1.416
## 4 426.6 0.919 213.0 0.894 34.3 0.109
## 5 438.1 0.982 167.2 0.160 39.4 1.089
## 6 259.1 0.000 197.4 0.644 25.4 -1.601
# Cement naturally "shouts louder" because its range is wider:
round(range(concrete_imputed$cement), 0) # cement spread ~200 units## [1] 259 441
## [1] 157 220
How to read it: a z-score of +2 means “2 standard deviations above the site average” — instantly comparable across any quantity, from MPa to GPa.
Why? Algorithms do maths — they cannot multiply “clay” by 2. Soil types, road surface types, and material grades must become numbers first.
soil <- c(
"clay", "sand", "gravel", "clay", "sand", "gravel",
"clay", "clay", "sand", "gravel", "sand", "clay"
)
# Option 1: LABEL encoding - factor() stores categories as integer codes
soil_factor <- factor(soil)
soil_factor## [1] clay sand gravel clay sand gravel clay clay sand gravel
## [11] sand clay
## Levels: clay gravel sand
# Option 2: ONE-HOT encoding - a separate 1/0 column per category
onehot <- model.matrix(~ 0 + soil_factor)
colnames(onehot) <- levels(soil_factor)
head(onehot)## clay gravel sand
## 1 1 0 0
## 2 0 0 1
## 3 0 1 0
## 4 1 0 0
## 5 0 0 1
## 6 0 1 0
What is it? Feature engineering means creating new, more meaningful columns from raw measurements using engineering formulas. A good site engineer never uses raw numbers alone — they compute derived quantities. A good ML engineer does the same.
Depth alone lies! A 66 mm storm over 6 hours is gentle; 30 mm in 1 hour is a downpour. Drainage design cares about intensity:
\[I = \frac{\text{Rainfall depth } P \text{ (mm)}}{\text{Duration } t \text{ (hr)}} \quad \text{(mm/hr)}\]
storms <- data.frame(
storm_id = paste0("Storm ", 1:6),
depth_mm = c(15, 42, 30, 66, 8, 55),
duration_h = c(2, 6, 1, 3, 0.5, 5)
)
storms$intensity_mmh <- storms$depth_mm / storms$duration_h
storms[order(-storms$intensity_mmh), ]## storm_id depth_mm duration_h intensity_mmh
## 3 Storm 3 30 1.0 30.0
## 4 Storm 4 66 3.0 22.0
## 5 Storm 5 8 0.5 16.0
## 6 Storm 6 55 5.0 11.0
## 1 Storm 1 15 2.0 7.5
## 2 Storm 2 42 6.0 7.0
What is it? The runoff coefficient \(C\) tells what fraction of rain becomes surface flow (rest soaks in). Typical values:
| Surface | Typical \(C\) |
|---|---|
| Roof / pavement | 0.85–0.95 |
| Gravel | 0.40–0.60 |
| Lawn / grass | 0.10–0.30 |
| Forest | 0.05–0.25 |
Rational Method (practical form):
\[Q\,(\text{m}^3/\text{s}) = \frac{C \times i\,(\text{mm/hr}) \times A\,(\text{ha})}{360}\]
C_paved <- 0.90
C_grass <- 0.20
area_paved <- 15
area_grass <- 10 # hectares, total catchment = 25 ha
rain_i <- 50 # design storm intensity, mm/hr
C_weighted <- (C_paved * area_paved + C_grass * area_grass) / (area_paved + area_grass)
Q_current <- C_weighted * rain_i * 25 / 360
Q_allpaved <- 0.90 * rain_i * 25 / 360 # future scenario: everything concreted
c(
C_weighted = round(C_weighted, 2),
Q_now_m3s = round(Q_current, 2),
Q_future_m3s = round(Q_allpaved, 2)
)## C_weighted Q_now_m3s Q_future_m3s
## 0.62 2.15 3.12
What is it? The Normalized Difference Vegetation Index uses satellite reflectance to measure how green (healthy) land is:
\[NDVI = \frac{NIR - Red}{NIR + Red}\]
Healthy leaves reflect a lot of Near-Infrared (NIR) and absorb Red light, so NDVI → 1. Bare soil/water → near 0. An anomaly = this month’s NDVI minus the normal NDVI — a red flag for drought, deforestation, or irrigation failure.
ndvi_data <- data.frame(
month = factor(month.abb, levels = month.abb),
red = c(0.20, 0.22, 0.19, 0.15, 0.12, 0.10, 0.09, 0.10, 0.13, 0.16, 0.18, 0.21),
nir = c(0.25, 0.28, 0.32, 0.38, 0.45, 0.50, 0.52, 0.50, 0.44, 0.36, 0.30, 0.26)
)
ndvi_data$ndvi <- (ndvi_data$nir - ndvi_data$red) / (ndvi_data$nir + ndvi_data$red)
# "Normal" here = yearly mean (for teaching). In practice, compare each month
# against the 10-year average for that SAME month.
normal <- mean(ndvi_data$ndvi)
ndvi_data$anomaly <- round(ndvi_data$ndvi - normal, 3)
ndvi_data[, c("month", "ndvi", "anomaly")]## month ndvi anomaly
## 1 Jan 0.1111111 -0.291
## 2 Feb 0.1200000 -0.282
## 3 Mar 0.2549020 -0.147
## 4 Apr 0.4339623 0.032
## 5 May 0.5789474 0.177
## 6 Jun 0.6666667 0.265
## 7 Jul 0.7049180 0.303
## 8 Aug 0.6666667 0.265
## 9 Sep 0.5438596 0.142
## 10 Oct 0.3846154 -0.017
## 11 Nov 0.2500000 -0.152
## 12 Dec 0.1063830 -0.295
ggplot(ndvi_data, aes(x = month, y = anomaly, fill = anomaly > 0)) +
geom_col() +
scale_fill_manual(
values = c("TRUE" = "#2e7d32", "FALSE" = "#c62828"),
labels = c("FALSE" = "Below normal (stress)", "TRUE" = "Above normal"),
name = NULL
) +
geom_hline(yintercept = 0, color = "grey30") +
labs(
title = "NDVI anomaly: is the vegetation thriving or stressed?",
x = NULL, y = "NDVI anomaly"
) +
theme_minimal(base_size = 12)# We build this dataset properly in Section 4; the message is simple:
cor(strength, age) # raw curing age: weaker, confusing signal
cor(strength, log_age) # engineered log(age): much clearer signalConcrete gains strength fast early, slowly later — a
logarithmic pattern. Giving the model log(age) instead of
raw age is like handing a student a neatly organised
formula sheet instead of a pile of raw notes.
Training = the algorithm adjusts itself to fit the training set. Validation = checking it on the unseen test set. We use an 80/20 split: 80% for learning, 20% for honest examination.
set.seed(42)
n <- 120
concrete_ml <- data.frame(
cement = runif(n, 200, 500), # kg/m3
water = runif(n, 140, 230), # litres/m3
age = sample(c(3, 7, 14, 28, 90), n, replace = TRUE) # days
)
# --- Feature engineering (Section 3 in action!) ---
concrete_ml$log_age <- log10(concrete_ml$age)
concrete_ml$strength <- round(5 + 0.08 * concrete_ml$cement - 0.10 * concrete_ml$water +
12 * concrete_ml$log_age + rnorm(n, 0, 2.5), 1) # MPa
head(concrete_ml)## cement water age log_age strength
## 1 474.4418 172.1050 3 0.4771213 31.0
## 2 481.1226 176.9572 14 1.1461280 36.5
## 3 285.8419 191.6128 14 1.1461280 27.6
## 4 449.1343 193.0710 28 1.4471580 39.3
## 5 392.5237 204.7692 28 1.4471580 33.1
## 6 355.7288 175.5476 28 1.4471580 34.5
c(
"Correlation with raw age" = round(cor(concrete_ml$strength, concrete_ml$age), 3),
"Correlation with log(age)" = round(cor(concrete_ml$strength, concrete_ml$log_age), 3)
)## Correlation with raw age Correlation with log(age)
## 0.507 0.589
The engineered log_age carries a clearer signal — the
model will thank us.
set.seed(101)
train_idx <- sample(seq_len(nrow(concrete_ml)), size = floor(0.8 * nrow(concrete_ml)))
train_set <- concrete_ml[train_idx, ]
test_set <- concrete_ml[-train_idx, ]
c(train_rows = nrow(train_set), test_rows = nrow(test_set))## train_rows test_rows
## 96 24
##
## Call:
## lm(formula = strength ~ cement + water + log_age, data = train_set)
##
## Residuals:
## Min 1Q Median 3Q Max
## -6.5910 -1.6364 -0.1673 1.2929 5.2719
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 6.307838 2.110941 2.988 0.0036 **
## cement 0.080363 0.002615 30.729 <2e-16 ***
## water -0.107898 0.009368 -11.518 <2e-16 ***
## log_age 12.048630 0.475373 25.346 <2e-16 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 2.359 on 92 degrees of freedom
## Multiple R-squared: 0.9492, Adjusted R-squared: 0.9475
## F-statistic: 572.9 on 3 and 92 DF, p-value: < 2.2e-16
How to read the summary: Estimate = the
learned slope (e.g., each extra kg of cement adds ≈ 0.08 MPa). A tiny
Pr(>|t|) (like < 0.001) means “this relationship is
real, not luck”. We then ask the model to predict the test
set it has never seen:
test_pred <- predict(strength_model, newdata = test_set)
head(data.frame(actual = test_set$strength, predicted = round(test_pred, 1)))## actual predicted
## 1 31.0 31.6
## 5 33.1 33.2
## 12 37.2 36.2
## 13 28.6 29.4
## 21 44.2 41.4
## 27 31.2 32.5
Numbers that answer: “How wrong are the predictions, and is the model actually useful?”
| Metric | Formula | Plain English | Watch out for |
|---|---|---|---|
| MAE | \(\frac{1}{n}\sum\lvert y-\hat{y}\rvert\) | “Average size of my mistake” | Treats all errors equally |
| RMSE | \(\sqrt{\frac{1}{n}\sum (y-\hat{y})^2}\) | “Average mistake, but big errors punished extra” | Sensitive to outliers (can be good!) |
| R² | \(1-\frac{SS_{res}}{SS_{tot}}\) | “Share of variation I explained” (0–1) | High R² alone can hide big errors |
# Reusable metric functions (we will use them again later!)
mae <- function(actual, predicted) mean(abs(actual - predicted))
rmse <- function(actual, predicted) sqrt(mean((actual - predicted)^2))
r_squared <- function(actual, predicted) {
1 - sum((actual - predicted)^2) / sum((actual - mean(actual))^2)
}
knitr::kable(data.frame(
Metric = c("MAE (MPa)", "RMSE (MPa)", "R-squared"),
Value = c(
round(mae(test_set$strength, test_pred), 2),
round(rmse(test_set$strength, test_pred), 2),
round(r_squared(test_set$strength, test_pred), 3)
)
), caption = "Our model on the unseen test set")| Metric | Value |
|---|---|
| MAE (MPa) | 1.890 |
| RMSE (MPa) | 2.320 |
| R-squared | 0.915 |
Always compare with a lazy baseline — “guess the average strength for everything”:
baseline_pred <- rep(mean(train_set$strength), nrow(test_set))
c(
"RMSE of lazy baseline" = round(rmse(test_set$strength, baseline_pred), 2),
"RMSE of our model" = round(rmse(test_set$strength, test_pred), 2)
)## RMSE of lazy baseline RMSE of our model
## 8.40 2.32
ggplot(
data.frame(actual = test_set$strength, predicted = test_pred),
aes(x = actual, y = predicted)
) +
geom_point(color = "#2e86c1", size = 2.5, alpha = 0.8) +
geom_abline(
slope = 1, intercept = 0, linetype = "dashed",
color = "#c0392b", linewidth = 1
) +
labs(
title = "Predicted vs actual concrete strength (test set)",
subtitle = "Dashed line = perfect predictions; points hugging it = good model",
x = "Actual strength (MPa)", y = "Predicted strength (MPa)"
) +
theme_minimal(base_size = 12)Classification predicts a category — e.g., “Will this catchment flood this monsoon: Yes or No?” We judge it with a confusion matrix and its children: Accuracy, Precision, Recall, F1, and the ROC curve.
set.seed(7)
n <- 200
rain <- runif(n, 10, 120) # rainfall, mm
p_flood_true <- plogis((rain - 70) / 15) # true flood probability
actual <- factor(ifelse(runif(n) < p_flood_true, "Flood", "No Flood"),
levels = c("Flood", "No Flood")
)
p_model <- plogis((rain - 72) / 14 + rnorm(n, 0, 0.35)) # our model's opinion
predicted <- factor(ifelse(p_model >= 0.5, "Flood", "No Flood"),
levels = c("Flood", "No Flood")
)| Actually Flood | Actually No Flood | |
|---|---|---|
| We warned (Flood) | TP — correct siren! | FP — false alarm (costly evacuation) |
| We stayed quiet | FN — MISSED flood (dangerous!) | TN — correct silence |
cm <- table(Predicted = predicted, Actual = actual)
knitr::kable(cm, caption = "Confusion matrix for the flood-warning model")| Flood | No Flood | |
|---|---|---|
| Flood | 74 | 19 |
| No Flood | 15 | 92 |
\[\text{Accuracy} = \frac{TP+TN}{\text{All}} \quad \text{Precision} = \frac{TP}{TP+FP} \quad \text{Recall} = \frac{TP}{TP+FN} \quad F1 = \frac{2 \cdot P \cdot R}{P+R}\]
TP <- cm["Flood", "Flood"]
FP <- cm["Flood", "No Flood"]
FN <- cm["No Flood", "Flood"]
TN <- cm["No Flood", "No Flood"]
P <- TP / (TP + FP)
R <- TP / (TP + FN)
knitr::kable(data.frame(
Metric = c("Accuracy", "Precision", "Recall (Sensitivity)", "F1 Score"),
Formula = c("(TP+TN)/All", "TP/(TP+FP)", "TP/(TP+FN)", "2PR/(P+R)"),
Value = round(c((TP + TN) / sum(cm), P, R, 2 * P * R / (P + R)), 3)
))| Metric | Formula | Value |
|---|---|---|
| Accuracy | (TP+TN)/All | 0.830 |
| Precision | TP/(TP+FP) | 0.796 |
| Recall (Sensitivity) | TP/(TP+FN) | 0.831 |
| F1 Score | 2PR/(P+R) | 0.813 |
The 0.5 cut-off was our choice. Move it and watch the trade-off:
thr_table <- do.call(rbind, lapply(c(0.30, 0.50, 0.70), function(t) {
pred_t <- factor(ifelse(p_model >= t, "Flood", "No Flood"),
levels = c("Flood", "No Flood")
)
cm_t <- table(pred_t, actual)
TP <- cm_t["Flood", "Flood"]
FP <- cm_t["Flood", "No Flood"]
FN <- cm_t["No Flood", "Flood"]
TN <- cm_t["No Flood", "No Flood"]
data.frame(
Threshold = t,
Accuracy = round((TP + TN) / sum(cm_t), 3),
Precision = round(TP / (TP + FP), 3),
Recall = round(TP / (TP + FN), 3)
)
}))
knitr::kable(thr_table,
row.names = FALSE,
caption = "Lower threshold = more warnings = higher recall, more false alarms"
)| Threshold | Accuracy | Precision | Recall |
|---|---|---|---|
| 0.3 | 0.775 | 0.690 | 0.899 |
| 0.5 | 0.830 | 0.796 | 0.831 |
| 0.7 | 0.840 | 0.890 | 0.730 |
The ROC curve plots Recall (TPR) against False Alarm Rate (FPR) for every possible threshold. The Area Under the Curve (AUC) summarises it: 1.0 = perfect, 0.5 = coin-flip guessing.
thresholds <- seq(0, 1, by = 0.01)
tpr <- sapply(thresholds, function(t) sum(p_model >= t & actual == "Flood") / sum(actual == "Flood"))
fpr <- sapply(thresholds, function(t) sum(p_model >= t & actual == "No Flood") / sum(actual == "No Flood"))
roc_df <- data.frame(fpr, tpr)[order(fpr, tpr), ]
auc <- sum(diff(roc_df$fpr) * (head(roc_df$tpr, -1) + tail(roc_df$tpr, -1)) / 2) # trapezoid rule
c(AUC = round(auc, 3))## AUC
## 0.911
ggplot(roc_df, aes(x = fpr, y = tpr)) +
geom_line(color = "#8e44ad", linewidth = 1.3) +
geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "grey40") +
coord_equal(xlim = c(0, 1), ylim = c(0, 1)) +
labs(
title = paste0("ROC curve (AUC = ", round(auc, 2), ")"),
subtitle = "Dashed diagonal = random guessing. Closer to the top-left = better.",
x = "False Positive Rate (false alarms)",
y = "True Positive Rate (floods caught)"
) +
theme_minimal(base_size = 12)pROC or ROCR packages do this in one line
— and Unit 4’s logistic regression will produce these probabilities
directly.
The Goldilocks problem of ML: a model can be too simple, too complicated, or just right.
Groundwater follows a seasonal wave. We fit polynomials of degree 1 (a straight line), 4 (smooth curve), and 15 (a noodle) and compare training vs test RMSE:
set.seed(100)
n_fit <- 60
x_all <- runif(n_fit, 0, 12) # month of year
y_all <- 8 + 3 * sin(2 * pi * x_all / 12) + rnorm(n_fit, 0, 0.8) # water table (m)
split_idx <- sample(seq_len(n_fit), size = 0.7 * n_fit)
d_train <- data.frame(x = x_all[split_idx], y = y_all[split_idx])
d_test <- data.frame(x = x_all[-split_idx], y = y_all[-split_idx])degrees <- c(1, 4, 15)
results <- do.call(rbind, lapply(degrees, function(d) {
m <- lm(y ~ poly(x, d), data = d_train)
data.frame(
Degree = d,
Train_RMSE = round(rmse(d_train$y, predict(m)), 3),
Test_RMSE = round(rmse(d_test$y, predict(m, newdata = d_test)), 3)
)
}))
results$Verdict <- c("Too simple → UNDERFITS", "Good balance ", "Memorises noise → OVERFITS")
results## Degree Train_RMSE Test_RMSE Verdict
## 1 1 1.656 0.965 Too simple → UNDERFITS
## 2 4 0.896 0.929 Good balance
## 3 15 0.755 0.960 Memorises noise → OVERFITS
grid <- data.frame(x = seq(0, 12, length.out = 300))
grid$deg1 <- predict(lm(y ~ poly(x, 1), d_train), newdata = grid)
grid$deg4 <- predict(lm(y ~ poly(x, 4), d_train), newdata = grid)
grid$deg15 <- predict(lm(y ~ poly(x, 15), d_train), newdata = grid)
ggplot() +
geom_point(data = d_train, aes(x, y), color = "grey30", size = 2) +
geom_line(data = grid, aes(x, deg1, color = "1: Underfit"), linewidth = 1) +
geom_line(data = grid, aes(x, deg4, color = "4: Balanced"), linewidth = 1) +
geom_line(data = grid, aes(x, deg15, color = "15: Overfit"), linewidth = 1) +
scale_color_manual(values = c(
"1: Underfit" = "#c0392b",
"4: Balanced" = "#1e8449",
"15: Overfit" = "#7d3c98"
), name = "Polynomial degree") +
labs(
title = "Same data, three models — which would you trust on a new site?",
x = "Month", y = "Groundwater level (m)"
) +
theme_minimal(base_size = 12)Read the table: the degree-15 model has the lowest training error but its test error explodes — classic overfitting. The test row is the only row that tells the truth.
Total expected error decomposes as:
\[\text{Total Error} = \text{Bias}^2 + \text{Variance} + \text{Irreducible Noise}\]
Think of a dartboard: bias = systematically aiming wrong (darts clustered away from the bullseye); variance = shaky hands (darts scattered everywhere).
| Model | Bias | Variance | Behaviour |
|---|---|---|---|
| Degree 1 (straight line on a wave) | High | Low | Underfits |
| Degree 4 (smooth curve) | Medium | Medium | Just right |
| Degree 15 (noodle) | Low | High | Overfits |
How engineers fight it: more data, simpler models, fewer features, regularisation — and cross-validation to find the sweet spot, which brings us to…
A single train/test split can be lucky or unlucky. K-fold cross-validation rotates the exam: split training data into k folds, train on k−1, test on the remaining fold, repeat k times, and average. Every data point gets examined exactly once.
fold_demo <- matrix(" Train", nrow = 5, ncol = 5)
diag(fold_demo) <- " TEST"
knitr::kable(fold_demo,
col.names = paste0("Fold ", 1:5),
caption = "5-fold CV: in each round one red block is the test set"
)| Fold 1 | Fold 2 | Fold 3 | Fold 4 | Fold 5 |
|---|---|---|---|---|
| TEST | Train | Train | Train | Train |
| Train | TEST | Train | Train | Train |
| Train | Train | TEST | Train | Train |
| Train | Train | Train | TEST | Train |
| Train | Train | Train | Train | TEST |
set.seed(5)
k <- 5
folds <- sample(rep(1:k, length.out = nrow(train_set)))
cv_results <- data.frame(fold = 1:k, rmse_with_log_age = NA, rmse_without_log_age = NA)
for (i in 1:k) {
cv_train <- train_set[folds != i, ]
cv_test <- train_set[folds == i, ]
m_full <- lm(strength ~ cement + water + log_age, data = cv_train)
m_simple <- lm(strength ~ cement + water, data = cv_train)
cv_results$rmse_with_log_age[i] <- rmse(cv_test$strength, predict(m_full, newdata = cv_test))
cv_results$rmse_without_log_age[i] <- rmse(cv_test$strength, predict(m_simple, newdata = cv_test))
}
cv_results## fold rmse_with_log_age rmse_without_log_age
## 1 1 2.465418 5.831858
## 2 2 2.681817 6.701626
## 3 3 2.485740 7.733147
## 4 4 2.509193 6.283093
## 5 5 1.657015 6.748487
knitr::kable(data.frame(
Model = c("With log_age feature", "Without log_age"),
Mean_CV_RMSE = round(colMeans(cv_results[, 2:3]), 2),
SD_CV_RMSE = round(apply(cv_results[, 2:3], 2, sd), 2)
), caption = "Average of 5 exams is far more trustworthy than one exam")| Model | Mean_CV_RMSE | SD_CV_RMSE | |
|---|---|---|---|
| rmse_with_log_age | With log_age feature | 2.36 | 0.40 |
| rmse_without_log_age | Without log_age | 6.66 | 0.71 |
log_age feature
genuinely helps (lower mean RMSE) — this is the honest way to justify
design decisions, just like a load test averages several specimens
instead of trusting one cube.
| Concept | One-line takeaway |
|---|---|
| ML workflow | Ask → Collect → Prepare → Split → Train → Evaluate → Deploy |
| Cleaning | No NA, no duplicates, no impossible values before modelling |
| Min-Max / Z-score | Fair comparison when columns have different units/spreads |
| One-hot encoding | Words → 1/0 columns (for categories with no order) |
| Feature engineering | Rainfall intensity, runoff C, NDVI anomaly, log(age) = raw data made meaningful |
| Train/test split | Mock exam vs final exam — never leak |
| MAE / RMSE / R² | Avg error / avg error punishing blunders / % of variation explained |
| Precision / Recall | Alarm accuracy / floods caught — rare dangers need recall |
| F1 / ROC-AUC | Balance of P&R / quality across all thresholds |
| Overfitting | Memorises noise; train error ↓, test error ↑ |
| Bias–Variance | Too simple vs too wiggly — seek the sweet spot |
| K-fold CV | Average of k exams; the honest model selector |
Q1. Normalise cement = 350 kg/m³ given min = 200 and max = 500.
Q2. A storm drops 48 mm in 1.5 hours. Compute the rainfall intensity.
Q3. Using the Rational Method, find Q for C = 0.75, i = 40 mm/hr, A = 10 ha.
Q4. A flood model gives TP = 40, FP = 10, FN = 20, TN = 130. Find Accuracy, Precision, Recall, and F1.
Q5. Why can 99% accuracy be a disaster for flood prediction?
A1. \(x' = (350-200)/(500-200) = 0.5\)
A2. \(I = 48/1.5 = 32\) mm/hr.
A3. \(Q = 0.75 \times 40 \times 10 / 360 \approx 8.33\) m³/s.
A4. Accuracy = 170/200 = 0.85; Precision = 40/50 = 0.80; Recall = 40/60 ≈ 0.667; F1 = 2(0.8)(0.667)/(0.8+0.667) ≈ 0.727.
A5. If floods are 1% of cases, “always say No Flood” scores 99% accuracy but Recall = 0 — it misses every real flood, which is exactly the dangerous failure mode. Use Recall/F1 for rare, hazardous events.