# ---- Packages ----------------------------------------------------------------
library(torch) # neural networks: tensors, layers, optimizers, training
library(dplyr) # data wrangling: filter(), mutate(), summarise()
library(ggplot2) # plots
# ---- Random seeds ------------------------------------------------------------
# Neural networks start from random numbers, and we also simulate data and
# shuffle rows at random. Seeds make your results match this document.
# Change them and the details shift, but the story stays the same.
set.seed(1) # R's random numbers: simulated data and data splits
torch_manual_seed(1) # torch's random numbers: the network's starting weights
# ---- A score we will use everywhere -----------------------------------------
# Root mean squared error (RMSE): the typical size of a prediction miss, in
# the units of the thing you are predicting. Smaller is better.
# observed: the true values
# predicted: the model's guesses for the same rows
rmse = function(observed, predicted) sqrt(mean((observed - predicted)^2))
# ---- Colors used in every plot below -----------------------------------------
col_train = "#1b9e77" # training data (green)
col_valid = "#d95f02" # validation data (orange)
col_test = "#7570b3" # test data (purple)
col_linear = "grey45" # linear models (grey)
col_nn = "#2c7fb8" # neural networks (blue)Neural Networks in R
A crash course: from a straight line to a network that beats it
Before class: setup
You have two files: this document (neural_networks_in_R.qmd) and setup.R. That is everything. Do these steps before class, not during. They take about ten minutes, most of it waiting.
Install R (4.3 or newer) and RStudio. On Windows, open a terminal and run:
winget install RProject.R Posit.RStudioOr download both from https://posit.co/download/rstudio-desktop/. You do not need Rtools or Python.
Put both files in one folder that is not inside OneDrive, for example
C:\Users\you\neural-networks. OneDrive-synced folders make R package installs very slow on Windows.Open
setup.Rin RStudio and click Source (top right of the editor). It installs the packages, then downloads the engine that does the math for neural networks (about 200 MB, so use good Wi-Fi). It finishes by training a tiny network and telling you how fast your laptop is. ThreeOKs means you are ready.Open
neural_networks_in_R.qmdin RStudio. This is where you will work during class.
If step 3 fails, copy the red text and email it to me before class. If you cannot install software on your laptop at all, make a free account at posit.cloud, click New Project > New RStudio Project, upload both files with the Upload button in the Files pane, and do steps 3 and 4 there.
How to use this document
Run the chunks in order, top to bottom. Put your cursor in a chunk and press Ctrl+Shift+Enter (Cmd+Shift+Enter on a Mac), or click the green arrow at the top right of the chunk. Later chunks use objects made by earlier ones. If you restart R or something looks wrong, click inside the chunk you want and press Ctrl+Alt+P (Cmd+Option+P on a Mac). That runs every chunk above it.
Neural networks start from random numbers. If you run a chunk twice, the results will wiggle a little. That is normal, and it is part of the lesson.
Everything here runs on your laptop’s CPU, and every network is deliberately tiny, a few dozen to a few thousand weights. Most chunks finish in a second or two, and the slowest takes several seconds on an older laptop. Large models train on GPUs for days, but the loop, the piles and the hyperparameters are exactly the ones you will use today.
Workshop roadmap
This document is both the workshop and a reference for afterward. The live hour covers sections 1 to 4. Sections 5 and 6 are optional extensions to explore on your own.
| Minutes | Live session | Where |
|---|---|---|
| 0–10 | Why neural networks; a linear model as a network; weights, biases, gradient descent and the training loop | Sections 1–2 |
| 10–25 | Hidden layers, activation functions, a nonlinear species niche, model complexity | Section 3 |
| 25–45 | Training, validation and test; overfitting; early stopping; data leakage | Section 4 |
| 45–55 | The Shiny playground: five short experiments | “Play with it” in section 4 |
| 55–60 | Can you trust it? Key takeaways | “Before you go” in section 4 |
During the workshop: run each chunk in sections 1 to 4 as we reach it, and do the five playground experiments. “Why standardize?” in section 2 can be skimmed live. Leave the Try it boxes for later.
After the workshop: the Try it boxes, section 5 (k-fold cross-validation on real ozone data) and section 6 (recurrent networks for time series and convolutional networks for maps).
Where this is heading: the checklist at the end of the document has six questions to ask of any neural network you did not train yourself, including one a chatbot writes for you. By the end of the hour you should be able to answer them.
1 Why neural networks, and why now
A neural network is a function with a lot of adjustable numbers in it. You show it examples, measure how wrong it is, and nudge every number a little in the direction that makes it less wrong. Repeat a few thousand times. That is the whole idea.
What makes neural networks useful is that you do not tell them the shape of the relationship. A linear model needs you to say “straight line,” or “quadratic in temperature,” or “interaction between wind and sun.” A network finds bends and interactions on its own. In ecology that shows up in species distribution models, classifying land cover from satellite images, filling gaps in sensor records, and emulating slow process models.
What makes them dangerous is the same thing. A function flexible enough to find any shape is flexible enough to memorize noise. Most of this hour is about how to tell the difference: when a model is learning a real relationship, when it is learning noise, and whether its predictions can be trusted.
1.1 The one diagram that matters
Every honest model evaluation splits the data into three piles, each with one job.
flowchart LR A[(All your data)] --> T[Training<br/>fit the weights] A --> V[Validation<br/>choose the settings] A --> S[Test<br/>score once, at the end]
The training pile teaches the model. The validation pile is how you choose between versions of the model. The test pile is locked in a drawer until you have made every decision, then used exactly once to report how good the model is. Every concept today, from epochs to k-fold cross-validation, is about keeping those three piles honest.
Today we deal rows into the three piles at random. That is fine for learning the ideas, and for our simulated survey, where every plot is independent of the others. It is often not fine for real environmental observations. Nearby sites, repeat visits to the same site and neighboring days resemble each other. A random split puts near-copies of the evaluation rows into the training pile, and the model looks better than it will be in use.
The evaluation has to match the job the model will do:
- Predicting new locations? Hold out whole spatial blocks.
- Predicting future conditions? Train on earlier years and evaluate on later ones.
- Predicting new sites or watersheds? Hold out entire sites or basins.
Section 5 returns to this in more detail.
1.2 The LLM argument
Ask a chatbot for a neural network on your data and you will get one in thirty seconds. It will probably run. It may even look impressive.
Running is not the same as being right. Generated code can produce convincing numbers while relying on a weak baseline, a validation design that does not match the science, or information quietly leaking from the test data into training. Those mistakes make a model look better on paper than it will be in the field, and they are easy to make and hard to spot.
AI tools can often catch these problems when you ask, especially when you give them enough context. But whether a validation strategy is scientifically appropriate depends on things the code does not show: how the data were sampled, how observations depend on each other in space and time, and what the model will be used for. That judgment is yours.
If you understand the three piles, you can read generated code, supply that context and ask the right questions. Each section ends with one you can take to a model yourself.
2 A linear model is a tiny neural network
Start with something you already know. The airquality data that ships with R has daily ozone, temperature, wind and sunlight in New York in 1973. Hot days have more ozone.
# airquality ships with R: 153 days of New York air quality in 1973.
# na.omit() drops days with any missing measurement, leaving 111 days.
aq = na.omit(airquality)
# One point per day: ozone against that day's max temperature
ggplot(aq, aes(x = Temp, y = Ozone)) +
geom_point(color = col_train, alpha = 0.7) +
labs(x = "Daily max temperature (°F)", y = "Ozone (ppb)") +
theme_minimal(base_size = 14)Fit a straight line with lm(). One change from usual: we standardize temperature first, subtracting its mean and dividing by its standard deviation. The result, sometimes called a z-score, says how many standard deviations a day is above or below an average day. It does not change the line, only the units of the slope. Why it matters gets its own subsection below.
# Standardize: subtract the mean, then divide by the standard deviation.
# An average day (77.9 °F) becomes 0. A day one standard deviation warmer
# (about 87 °F) becomes 1. Most days land between -2 and 2.
aq$temp_z = (aq$Temp - mean(aq$Temp)) / sd(aq$Temp)
# The ordinary linear model. lm() solves for the best line exactly, in one step.
fit_lm = lm(Ozone ~ temp_z, data = aq)
# Intercept: ozone on an average-temperature day.
# Slope: extra ppb of ozone for each standard deviation of temperature.
coef(fit_lm)(Intercept) temp_z
42.09910 23.24464
Now fit the same model as a neural network. A network with no hidden layer is one weight times the input, plus one bias: y = w * x + b. That is a straight line. The difference is how it is fit. lm() solves for the answer directly. A network starts from random numbers and walks downhill.
# ---- The data, in torch's format ---------------------------------------------
# torch works on "tensors": arrays it can do calculus on. A layer expects a
# matrix with one row per observation and one column per input.
x = torch_tensor(matrix(aq$temp_z)) # inputs: 111 days x 1 column
# The answers. as.numeric() because torch needs decimals, and Ozone is
# stored as whole numbers.
y = torch_tensor(matrix(as.numeric(aq$Ozone)))
# ---- The model ---------------------------------------------------------------
# y = w * x + b: one weight w (the slope) and one bias b (the intercept).
# Both start as random numbers. The seed makes them the same every run.
torch_manual_seed(1)
net = nn_linear(in_features = 1, out_features = 1)
# ---- The optimizer -----------------------------------------------------------
# The optimizer does the walking downhill. SGD is stochastic gradient descent:
# every step, each number moves by (learning rate x its gradient).
optimizer = optim_sgd(net$parameters, lr = 0.1) # lr = learning rate: the step size
# ---- The training loop -------------------------------------------------------
losses = numeric(100) # somewhere to record the loss after each epoch
for (epoch in 1:100) { # 100 epochs = 100 passes through all 111 days
# 1. Forget the last step's gradients. torch adds them up otherwise.
optimizer$zero_grad()
# 2. Forward pass: predict ozone for every day.
pred = net(x)
# 3. Loss: mean squared error between the predictions and the answers.
loss = nnf_mse_loss(pred, y)
# 4. Backward pass: which way should each number move, and how steeply?
loss$backward()
# 5. Move each number a small step downhill.
optimizer$step()
losses[epoch] = loss$item() # $item() pulls one plain number out of a tensor
}
# ---- Did it find the same line as lm()? --------------------------------------
c(network_slope = net$weight$item(), lm_slope = unname(coef(fit_lm)[2]),
network_intercept = net$bias$item(), lm_intercept = unname(coef(fit_lm)[1])) network_slope lm_slope network_intercept lm_intercept
23.24464 23.24464 42.09909 42.09910
Same answer. Here is the walk downhill:
# The loss after each of the 100 epochs: high at the random start, then down
tibble(epoch = 1:100, loss = losses) |>
ggplot(aes(x = epoch, y = loss)) +
geom_line(color = col_nn, linewidth = 1) +
labs(x = "Epoch", y = "Training loss (MSE)") +
theme_minimal(base_size = 14)Those five lines inside the loop are every neural network you will ever train, from this one to the models behind chatbots:
- Zero the gradients left over from the last step.
- Make predictions with the current weights. This is the forward pass.
- Calculate the loss: how wrong those predictions are.
- Backpropagate: work out which way each weight should move to shrink the loss.
- Update the weights: move each one a small step that way.
The vocabulary:
- Weights and biases: the adjustable numbers. Here there are two. Large models have billions.
- Loss: one number that says how wrong the model is. Mean squared error for predicting amounts.
- Gradient: for each weight, which direction makes the loss go down, and how steeply.
loss$backward()computes all of them at once. - Learning rate: how big a step to take downhill. Our first hyperparameter: a setting you choose, not one the model learns.
- Epoch: one pass through all the training data.
Change the learning rate to 0.01, then 0.5, then 1, rerunning the loop each time. What happens to the loss curve and to the final slope? Which one would you call “best,” and would you expect the same value to be best on a different problem?
Paste the training loop into a chatbot and ask: “Explain what each of the five lines inside this loop does. What would go wrong if I deleted optimizer$zero_grad()?” Then delete it and see if the model was right.
2.1 Why standardize?
Here is the same loop on raw temperatures, 57 to 97 °F, instead of standardized ones. First with the step size that just worked, then with a step 1,000 times smaller, then with that small step for 50 times as many epochs.
# The loop above, wrapped in a function so we can try different settings.
# x_input: the input tensor to train on
# lr: learning rate
# epochs: number of passes through the data
# Returns one row: the final loss, slope and intercept.
gd_line = function(x_input, lr, epochs = 100) {
torch_manual_seed(1) # same random start as before
net = nn_linear(1, 1) # one weight, one bias
optimizer = optim_sgd(net$parameters, lr = lr)
for (epoch in 1:epochs) {
optimizer$zero_grad()
loss = nnf_mse_loss(net(x_input), y)
loss$backward()
optimizer$step()
}
tibble(learning_rate = lr, epochs = epochs, final_loss = loss$item(),
slope = net$weight$item(), intercept = net$bias$item())
}
# Raw temperatures in °F, NOT standardized
x_raw = torch_tensor(matrix(as.numeric(aq$Temp)))
bind_rows(
gd_line(x_raw, lr = 0.1), # the step size that worked above
gd_line(x_raw, lr = 0.0001), # a step 1,000 times smaller
gd_line(x_raw, lr = 0.0001, epochs = 5000) # the small step, 50 times longer
)# A tibble: 3 × 5
learning_rate epochs final_loss slope intercept
<dbl> <dbl> <dbl> <dbl> <dbl>
1 0.1 100 NaN NaN NaN
2 0.0001 100 879. 0.575 -0.484
3 0.0001 5000 870. 0.602 -2.58
# For comparison, the right answer in raw units, from lm():
coef(lm(Ozone ~ Temp, data = aq))(Intercept) Temp
-147.64607 2.43911
With the step size that worked before, the loss explodes and every number turns into NaN, “not a number.” Shrink the step 1,000 times and it stops exploding, but it barely moves. Even after 5,000 epochs the line it has found runs close to the origin, with a slope of about 0.6 and an intercept near zero. The right answer is a slope of 2.4 and an intercept of −148.
The reason is the shape of the problem. Picture the loss as a landscape over the two numbers the model is learning, the slope and the intercept. Every gradient for the slope gets multiplied by the input values, so with temperatures around 80 the landscape is a long, narrow canyon. It is extremely steep across the slope direction and nearly flat along the intercept direction. One step size has to serve both. A step big enough to travel along the flat floor launches you off the steep walls, which is the explosion. A step small enough to stay on the walls crawls along the floor and would need millions of epochs. Standardizing turns the canyon into a round bowl, where one step size suits every direction. Same data, same line, different units.
Networks care about this even more than straight lines do:
- Several inputs, several units. In section 5 sunlight runs from 7 to 334, wind from 2 to 21 and temperature from 57 to 97. Unstandardized, the input with the biggest numbers dominates every step, whether or not it matters most.
- Starting weights. torch starts every weight and bias at a small random number that assumes inputs between about −2 and 2. That places each hidden unit’s kink somewhere in that range. Feed it 57 to 97 and every kink sits far from the data, so units start out stuck all the way on or all the way off.
- Weight decay pulls every weight toward zero by the same rule. That is only fair if every input is on the same scale.
- The output, too. We also standardize the thing we predict, such as abundance or ozone. A new network’s guesses sit near 0 with a spread of about 1, so standardized answers start it in the right ballpark. Predictions are converted back with the same mean and standard deviation.
One rule, which section 4 explains: the mean and standard deviation come from the training data only, and those same two numbers are then applied to everything else.
4 Train, validation, test
Everything so far was scored on the data it was trained on. That tells you how well a model can memorize, not how well it will predict. To see the difference we need less data and more noise, which is what real field data usually looks like.
set.seed(2) # a new seed for a new, smaller survey
# Same species, but only 150 plots and noisier readings (sd = 8 instead of 5)
small = tibble(temp = runif(150, 0, 30)) |>
mutate(abundance = true_curve(temp) + rnorm(n(), 0, 8))
# Deal the 150 rows into three piles at random: 90 train, 30 validation, 30 test.
# Random is fine here because every simulated plot is independent.
# rep() makes the 150 labels, and sample() shuffles them onto the rows.
small$pile = sample(rep(c("train", "validation", "test"), times = c(90, 30, 30)))
count(small, pile) # check the pile sizes# A tibble: 3 × 2
pile n
<chr> <int>
1 test 30
2 train 90
3 validation 30
train = filter(small, pile == "train") # fits the weights
valid = filter(small, pile == "validation") # chooses the settings
test = filter(small, pile == "test") # into the drawer until the very endOne quiet but important step: the standardizing numbers must come from the training pile only. If the mean and standard deviation include validation or test rows, information about those rows has leaked into training. Here the effect is small. In real projects, leakage like this is one of the most common reasons a model looks better on paper than in the field.
# Overwrite the standardizing numbers using the TRAINING rows only.
# to_x(), to_y(), from_y() and predict_nn() from section 3 read these four
# numbers, so from here on they are leak-free. The validation and test rows
# get standardized with the training pile's mean and sd, not their own.
x_mean = mean(train$temp); x_sd = sd(train$temp)
y_mean = mean(train$abundance); y_sd = sd(train$abundance)Now train a network that is too big for 90 points, 64 hidden units for 600 epochs, and watch the loss on both the training and validation piles as it goes.
# Train on the training pile, and hand over the validation pile so the
# function records its loss after every epoch. It never trains on it.
torch_manual_seed(1)
big = train_nn(to_x(train$temp), to_y(train$abundance), hidden = 64, epochs = 600,
x_val = to_x(valid$temp), y_val = to_y(valid$abundance))
# The epoch where validation loss was lowest
best_epoch = which.min(big$history$validation)
# Both loss curves on one plot. pivot_longer() stacks the train and
# validation columns into one, so ggplot can color them by pile.
big$history |>
tidyr::pivot_longer(c(train, validation), names_to = "pile", values_to = "loss") |>
ggplot(aes(x = epoch, y = loss, color = pile)) +
geom_line(linewidth = 1) +
geom_vline(xintercept = best_epoch, linetype = "dashed") +
annotate("text", x = best_epoch, y = max(big$history$validation), hjust = -0.1,
label = paste("best validation loss at epoch", best_epoch)) +
scale_color_manual(values = c(train = col_train, validation = col_valid)) +
labs(x = "Epoch", y = "Loss (standardized MSE)", color = NULL) +
theme_minimal(base_size = 14) +
theme(legend.position = "top")This is the most important plot in machine learning. Training loss keeps falling: the network keeps getting better at the data it has seen. Validation loss falls, bottoms out, then climbs: past that point the network is learning the noise in those 90 rows, not the niche. That is overfitting, and you can only see it because the validation pile was kept out of training.
Stopping at the bottom of the validation curve is called early stopping. Let’s compare the overtrained network with one stopped at the best epoch, on the training pile:
# Retrain from the same starting weights, but stop at the best epoch. With
# the same seed and the same data it follows exactly the same path, so this
# is the network as it was at that moment.
torch_manual_seed(1)
stopped = train_nn(to_x(train$temp), to_y(train$abundance),
hidden = 64, epochs = best_epoch)
# The training points, the truth, and both networks' curves
stopped_label = paste(best_epoch, "epochs") # legend label, e.g. "95 epochs"
ggplot(train, aes(x = temp, y = abundance)) +
geom_point(color = col_train, alpha = 0.6) +
geom_line(data = grid, aes(y = true_curve(temp)), linewidth = 0.8) +
geom_line(data = grid, aes(y = predict_nn(big$net, temp), color = "600 epochs"),
linewidth = 1.1) +
geom_line(data = grid, aes(y = predict_nn(stopped$net, temp), color = stopped_label),
linewidth = 1.1) +
scale_color_manual(values = setNames(c(col_valid, col_nn),
c("600 epochs", stopped_label))) +
labs(x = "Temperature (°C)", y = "Abundance index", color = NULL) +
theme_minimal(base_size = 14) +
theme(legend.position = "top")The overtrained curve bends to chase individual points. The early-stopped one tracks the true niche.
Every choice is now made: architecture, epochs, everything. Only now do we open the drawer and score the test pile, once:
# The first and only time the test pile is used. Each model was trained on
# the training pile, and every choice was made with the validation pile.
tibble(
model = c("Straight line", "Network, 600 epochs",
paste("Network,", best_epoch, "epochs")),
test_rmse = c(rmse(test$abundance, predict(lm(abundance ~ temp, data = train), test)),
rmse(test$abundance, predict_nn(big$net, test$temp)),
rmse(test$abundance, predict_nn(stopped$net, test$temp)))
)# A tibble: 3 × 2
model test_rmse
<chr> <dbl>
1 Straight line 15.0
2 Network, 600 epochs 9.11
3 Network, 95 epochs 7.03
4.1 Play with it: five experiments
This Shiny app trains the same network on the same kind of survey, with the amount of data and every hyperparameter in the sidebar. Press Train after changing settings, then compare the three scores and both loss curves. “Off from the true niche” is the score that matters most here, and it is only possible because we invented the species. The test pile never appears in the app, on purpose.
Take about ten minutes for these five experiments. Start each one from the app’s opening settings (150 plots, 16 units, 300 epochs, learning rate 0.03, no weight decay, ReLU), and change only what it says.
- More complexity, same data. Train with 2, then 16, then 64 hidden units. Does validation error keep improving as the network grows?
- Training too long. Set 64 hidden units, then train for 100, 300 and 1,000 epochs. Watch the validation curve and the distance from the true niche.
- More data. Keep 64 units and 1,000 epochs, and try 30, 150, 600 and 1,500 survey plots.
- Regularization. Back to 150 plots, with 64 units and 1,000 epochs. Raise the weight decay from 0 to 0.01, then to 0.1.
- Is more complicated always better? Look back at your best validation score from experiments 1, 2 and 4. Which settings won, and was it the biggest, longest-trained network?
Run the chunks above first, since the app uses small, train_nn() and true_curve(). If you have changed train_nn(), rerun everything above with Ctrl+Alt+P (Cmd+Option+P on a Mac) before starting the app.
library(shiny)
library(bslib)
# ---- A bigger survey to draw from ---------------------------------------------
# The first 150 rows are this section's survey (`small`), so 150 plots in the
# app is exactly the data above. The rest are 1,350 extra plots of the same
# species, dealt into the same three piles in the same 60/20/20 proportions.
# The slider takes the first n rows, so more data only ever ADDS plots.
set.seed(10)
extra = tibble(temp = runif(1350, 0, 30)) |>
mutate(abundance = true_curve(temp) + rnorm(n(), 0, 8),
pile = sample(c("train", "validation", "test"), n(), replace = TRUE,
prob = c(0.6, 0.2, 0.2)))
pool = bind_rows(small, extra) # 1,500 plots in all
# ---- UI: data and hyperparameters, three scores, two plots --------------------
ui = page_sidebar(
title = "Neural network playground",
sidebar = sidebar(
# How much data: the most important setting of all
sliderInput("n_samples", "Survey plots", min = 30, max = 1500,
value = 150, step = 30),
textOutput("pile_sizes"), # how many plots landed in each pile
# Every hyperparameter from the table in section 3, small enough for any laptop
selectInput("hidden", "Hidden units",
choices = c(1, 2, 4, 8, 16, 32, 64), selected = 16),
sliderInput("epochs", "Epochs", min = 50, max = 1000, value = 300, step = 50),
selectInput("lr", "Learning rate", choices = c(0.003, 0.03, 0.3), selected = 0.03),
selectInput("decay", "Weight decay",
choices = c(0, 0.0001, 0.001, 0.01, 0.1), selected = 0),
selectInput("activation", "Activation",
choices = c("ReLU (kinked)" = "relu", "tanh (smooth S)" = "tanh",
"Softplus (rounded ReLU)" = "softplus")),
plotOutput("bend_plot", height = 140), # a picture of the chosen bend
# Nothing trains until this button is pressed
actionButton("go", "Train", class = "btn-primary"),
p("Bigger settings take a few seconds on a slow laptop.")
),
# Top row: three scores, in abundance units
layout_columns(
fill = FALSE,
value_box(title = "Training RMSE", value = textOutput("train_rmse")),
value_box(title = "Validation RMSE", value = textOutput("valid_rmse")),
value_box(title = "Off from the true niche", value = textOutput("truth_rmse"))
),
# Bottom row: the fitted curve, and both loss curves
layout_columns(
card(plotOutput("fit_plot")),
card(plotOutput("loss_plot"))
)
)
# ---- Server ------------------------------------------------------------------
server = function(input, output, session) {
# Everything that happens when Train is pressed:
# 1. take the first n plots from the pool and split them into piles
# 2. standardize with THIS training pile's mean and sd (no leakage)
# 3. train a network with the chosen settings
# Selects send their values as text, so as.integer() and as.numeric() turn
# them back into numbers. bindEvent(): only retrain when the button is
# pressed, not on every slider wiggle. ignoreNULL = FALSE also trains once
# at startup, so the plots are not empty.
model = reactive({
survey = pool[seq_len(input$n_samples), ]
tr = filter(survey, pile == "train")
va = filter(survey, pile == "validation")
# Standardizing numbers from this training pile only
x_mu = mean(tr$temp); x_s = sd(tr$temp)
y_mu = mean(tr$abundance); y_s = sd(tr$abundance)
sx = function(temp) torch_tensor(matrix((temp - x_mu) / x_s))
sy = function(ab) torch_tensor(matrix((ab - y_mu) / y_s))
torch_manual_seed(1)
fit = train_nn(sx(tr$temp), sy(tr$abundance),
hidden = as.integer(input$hidden), epochs = input$epochs,
lr = as.numeric(input$lr), decay = as.numeric(input$decay),
activation = input$activation,
x_val = sx(va$temp), y_val = sy(va$abundance))
# Hand back the piles, the loss history, and a prediction function
list(train = tr, valid = va, history = fit$history,
predict = function(temp) {
as.numeric(with_no_grad(fit$net(sx(temp)))) * y_s + y_mu
})
}) |>
bindEvent(input$go, ignoreNULL = FALSE)
# The chosen bend, drawn as soon as the dropdown changes. No training
# needed: it is just the activation function applied to -4 ... 4.
output$bend_plot = renderPlot({
z = seq(-4, 4, length.out = 201)
out = switch(input$activation,
relu = nnf_relu(torch_tensor(z)),
tanh = torch_tanh(torch_tensor(z)),
softplus = nnf_softplus(torch_tensor(z))
)
ggplot(tibble(z = z, out = as.numeric(out)), aes(z, out)) +
geom_hline(yintercept = 0, color = "grey75") +
geom_vline(xintercept = 0, color = "grey75") +
geom_line(color = col_nn, linewidth = 1.2) +
labs(x = "in", y = "out") +
theme_minimal(base_size = 10)
})
# How the current survey was split
output$pile_sizes = renderText({
m = model()
sprintf("%d training and %d validation plots", nrow(m$train), nrow(m$valid))
})
# The three scores. All re-run whenever model() changes.
output$train_rmse = renderText({
m = model()
sprintf("%.2f", rmse(m$train$abundance, m$predict(m$train$temp)))
})
output$valid_rmse = renderText({
m = model()
sprintf("%.2f", rmse(m$valid$abundance, m$predict(m$valid$temp)))
})
# Only possible because we invented the species: how far the network's
# curve is from the real niche, across the whole temperature range
output$truth_rmse = renderText({
m = model()
sprintf("%.2f", rmse(true_curve(grid$temp), m$predict(grid$temp)))
})
# Left plot: both piles of points, the true niche, and the network's curve
output$fit_plot = renderPlot({
m = model()
dot_alpha = if (nrow(m$train) > 300) 0.35 else 0.7 # fade dots when crowded
ggplot() +
geom_point(data = m$train, aes(x = temp, y = abundance, color = "train"),
alpha = dot_alpha) +
geom_point(data = m$valid, aes(x = temp, y = abundance, color = "validation"),
alpha = dot_alpha) +
geom_line(data = grid, aes(x = temp, y = true_curve(temp)), linewidth = 0.8) +
geom_line(data = tibble(temp = grid$temp, fit = m$predict(grid$temp)),
aes(x = temp, y = fit), color = col_nn, linewidth = 1.2) +
scale_color_manual(values = c(train = col_train, validation = col_valid)) +
labs(x = "Temperature (°C)", y = "Abundance index", color = NULL) +
theme_minimal(base_size = 14) + theme(legend.position = "top")
})
# Right plot: training and validation loss after every epoch
output$loss_plot = renderPlot({
model()$history |>
tidyr::pivot_longer(c(train, validation),
names_to = "pile", values_to = "loss") |>
ggplot(aes(x = epoch, y = loss, color = pile)) +
geom_line(linewidth = 1) +
scale_color_manual(values = c(train = col_train, validation = col_valid)) +
labs(x = "Epoch", y = "Loss", color = NULL) +
theme_minimal(base_size = 14) + theme(legend.position = "top")
})
}
shinyApp(ui, server)In the playground, find settings where the validation curve turns back up. If you did not finish the five experiments above, finish them now. In experiment 3, why do both errors stop improving at around the same number?
Then switch the activation to tanh, with 64 hidden units and 1,000 epochs, and no weight decay. Is a smooth network safe from overfitting?
Back in the document, change set.seed(2) at the top of this section to set.seed(3), then rerun the section. Does the best epoch stay the same? What does that tell you about trusting one validation pile of 30 points?
“I tuned my neural network by trying different settings and keeping whichever gave the lowest error on my test set. Then I reported that test error in my paper. What is wrong with this, and how should I split my data instead?”
4.2 Before you go: can you trust it?
Five things to take with you:
- A neural network is a flexible function fit by walking downhill. With no hidden layer, it is linear regression.
- Hidden units and their bends find nonlinear shapes, like a species niche, without being told the shape.
- The same flexibility lets it memorize noise. Only data it never trained on can tell the two apart.
- Each pile has one job. Fit the weights with training data, choose settings with validation data, and score once with test data. Compute any preprocessing, such as standardizing, from the training pile only.
- The split has to match the use. Random piles were fine for our simulated plots. Real environmental data usually needs spatial, temporal or site-level separation.
The checklist at the end turns these into six questions to ask of any model you did not train yourself, including one a chatbot just wrote for you.
Sections 5 and 6 are optional extensions for after the workshop: k-fold cross-validation on real ozone data, and how recurrent and convolutional networks handle time and space.
5 k-fold cross-validation: a fair fight on real data (optional)
An optional extension for after the workshop. It trains 25 small networks, which takes about 3 seconds on a recent laptop and up to 15 on an older one.
The last Try it showed the weakness of one validation pile: with 30 points, the best setting depends on which 30 you happened to draw. Real ecological data is often that small. k-fold cross-validation fixes it by taking turns.
Split the non-test data into k chunks, called folds. Train on all but one fold and validate on the one left out. Repeat until every fold has been the validation pile once, then average. Every row gets used for validation exactly once, and you get k scores instead of one, so you can also see how much they vary.
Back to ozone, now with all three weather variables. The question: can a neural network beat a linear model at predicting ozone, fairly?
# The 111 complete days again, keeping ozone and the three weather inputs:
# sunlight (Solar.R, langleys), wind (mph) and temperature (°F)
aq = na.omit(airquality) |> select(Ozone, Solar.R, Wind, Temp)
preds = c("Solar.R", "Wind", "Temp") # the input columns, by name
# ---- Step 1: lock away a test set before doing anything else -----------------
set.seed(4)
test_rows = sample(nrow(aq), 22) # 22 random days, about 20%
aq_test = aq[test_rows, ] # into the drawer
aq_dev = aq[-test_rows, ] # "development" data: everything else
c(development = nrow(aq_dev), test = nrow(aq_test))development test
89 22
We need a network-fitting function for three inputs. Notice where the scaling numbers come from: the training data it is handed, and nothing else. Inside a cross-validation loop that is what keeps each fold’s validation rows unseen.
# Fit a network that predicts ozone from the three weather inputs.
# train_data: the rows to train on (a data frame)
# hidden, decay, epochs, lr: hyperparameters, passed on to train_nn()
# Returns a FUNCTION that predicts ozone for any new rows. The function
# carries the training data's standardizing numbers inside it.
fit_ozone_nn = function(train_data, hidden = 16, decay = 0, epochs = 150, lr = 0.03) {
# Standardizing numbers from THIS training data only
mu = colMeans(train_data[preds]); s = sapply(train_data[preds], sd) # one per input
y_mu = mean(train_data$Ozone); y_s = sd(train_data$Ozone) # for the output
# scale() standardizes every column at once, using the numbers we give it
x = torch_tensor(scale(as.matrix(train_data[preds]), mu, s)) # rows x 3 inputs
y = torch_tensor(matrix((train_data$Ozone - y_mu) / y_s)) # rows x 1 answer
fit = train_nn(x, y, hidden = hidden, epochs = epochs, lr = lr, decay = decay)
# Hand back a prediction function. It standardizes new rows with the
# TRAINING numbers, predicts, and converts back to ppb of ozone.
function(new_data) {
x_new = torch_tensor(scale(as.matrix(new_data[preds]), mu, s))
as.numeric(with_no_grad(fit$net(x_new))) * y_s + y_mu
}
}Step 2: five-fold cross-validation on the development data. The linear model gets the same folds as four versions of the network, so the comparison is fair.
# ---- Assign every development row to one of 5 folds, at random --------------
k = 5
set.seed(5)
# A fold number, 1 to 5, for each row, shuffled
folds = sample(rep(1:k, length.out = nrow(aq_dev)))
table(folds) # about 18 days per foldfolds
1 2 3 4 5
18 18 18 18 17
# ---- The network settings to compare: 2 sizes x 2 weight decays = 4 ----------
configs = expand.grid(hidden = c(8, 32), decay = c(0, 0.01))
# ---- The cross-validation loop ----------------------------------------------
results = list() # one row per model per fold goes in here
for (fold in 1:k) {
# This round's piles: one fold validates, the other four train
fold_train = aq_dev[folds != fold, ]
fold_valid = aq_dev[folds == fold, ]
# The linear model: train on the four folds, score on the fifth
linear = lm(Ozone ~ Solar.R + Wind + Temp, data = fold_train)
results[[length(results) + 1]] = tibble(
fold = fold, model = "Linear model",
rmse = rmse(fold_valid$Ozone, predict(linear, fold_valid))
)
# Each network setting: same training rows, same validation rows
for (i in seq_len(nrow(configs))) {
torch_manual_seed(fold) # reproducible starting weights
predict_ozone = fit_ozone_nn(fold_train, hidden = configs$hidden[i],
decay = configs$decay[i])
results[[length(results) + 1]] = tibble(
fold = fold,
model = sprintf("Network: %d units, decay %g",
configs$hidden[i], configs$decay[i]),
rmse = rmse(fold_valid$Ozone, predict_ozone(fold_valid))
)
}
}
cv = bind_rows(results) # 5 folds x 5 models = 25 rows
# ---- Summarize: average score and fold-to-fold spread for each model ---------
cv_summary = cv |>
summarise(mean_rmse = mean(rmse), sd_rmse = sd(rmse), .by = model) |>
arrange(mean_rmse) # best (lowest error) first
cv_summary# A tibble: 5 × 3
model mean_rmse sd_rmse
<chr> <dbl> <dbl>
1 Network: 32 units, decay 0 17.8 3.65
2 Network: 32 units, decay 0.01 18.1 2.67
3 Network: 8 units, decay 0.01 19.9 4.32
4 Network: 8 units, decay 0 20.6 4.50
5 Linear model 21.5 4.56
# One row per model, ordered by average error. Small dots are the five folds,
# and the black diamond is their mean.
ggplot(cv, aes(x = rmse, y = reorder(model, -rmse, FUN = mean))) +
geom_point(aes(color = model == "Linear model"), size = 2.5, alpha = 0.6) +
geom_point(data = cv_summary, aes(x = mean_rmse, y = model), shape = 18, size = 5) +
scale_color_manual(values = c(`TRUE` = col_linear, `FALSE` = col_nn),
guide = "none") +
labs(x = "Validation RMSE (ppb ozone)", y = NULL,
subtitle = "Dots are the five folds. Diamonds are the means.") +
theme_minimal(base_size = 13)Each row of dots is five honest scores. The spread matters as much as the mean. If two models’ dots overlap heavily, the data cannot tell them apart.
Step 3: choose the best setting using cross-validation only, refit it on all the development data, and score the test set once.
# ---- Choose: the network setting with the lowest cross-validation error ------
best = cv_summary |> filter(model != "Linear model") |> slice(1)
# Find that setting's row in configs, so we can reuse its numbers
config_names = sprintf("Network: %d units, decay %g", configs$hidden, configs$decay)
best_config = configs[config_names == best$model, ]
best$model[1] "Network: 32 units, decay 0"
# ---- Refit both finalists on ALL the development data ------------------------
final_linear = lm(Ozone ~ Solar.R + Wind + Temp, data = aq_dev)
torch_manual_seed(1)
final_nn = fit_ozone_nn(aq_dev, hidden = best_config$hidden, decay = best_config$decay)
# ---- Open the drawer: score the test set, once -------------------------------
tibble(
model = c("Linear model", best$model),
test_rmse = c(rmse(aq_test$Ozone, predict(final_linear, aq_test)),
rmse(aq_test$Ozone, final_nn(aq_test)))
)# A tibble: 2 × 2
model test_rmse
<chr> <dbl>
1 Linear model 20.9
2 Network: 32 units, decay 0 16.4
A few honest caveats. The test set is only 22 days, so its score is noisy. The cross-validation means are the stronger evidence, and the test is the final sanity check. And the linear model here is the plain textbook one. A linear model with the right transformations can close much of the gap. The network’s advantage is that it finds those shapes without being told, and its costs are the tuning and the interpretability.
Random folds assume every row is independent. Field data rarely is. Days next to each other have similar weather, plots next to each other share soil, and repeat visits share a site. When near-duplicate rows land in both training and validation, the model is graded on questions it has already seen, and the score looks better than it will be in a new place or a new year. The fix is to split by blocks: by year, by site, or by spatial block. Roberts et al. (2017), Cross-validation strategies for data with temporal, spatial, hierarchical, or phylogenetic structure, in Ecography, is the standard reference.
Give the linear model a fairer shot. In the cross-validation loop, change its formula to Ozone ~ Solar.R + Wind + Temp + I(Temp^2) + I(Wind^2) and rerun. Does the gap to the network shrink? Which result would you report?
Paste the cross-validation chunk and ask: “Check this for data leakage. Is anything computed from the full dataset, or from the validation fold, before the model is trained on each fold?” Then ask it what would change if these rows were daily measurements from the same summer.
Appendix: reading a model you did not train
Use this on your own models, on models in papers, and above all on code a chatbot writes for you.
- Does it beat a simple baseline? A network that cannot beat a linear model or a seasonal average is not earning its complexity.
- What was each pile used for? Training fits weights, validation chooses settings, test scores once. Look for any place the test set influenced a choice.
- Where could information leak? Scaling, filling missing values, or choosing variables using all the data. Near-duplicate rows across piles.
- Do the splits match the use? If the model will predict new sites or new years, it should be validated on held-out sites or years.
- How variable are the scores? One validation number hides a lot. Fold-to-fold spread shows whether a difference is real.
- Were the hyperparameters tuned, and how? Default settings can be fine. Settings tuned on the test set are not.
If you can answer those for a model, you can direct a chatbot to fix it. If you cannot, you are trusting its defaults.
Resources
- torch for R: documentation, tutorials and articles.
- Tidy Modeling with R, Kuhn and Silge. Free. The chapters on resampling and tuning cover today’s ideas in production form.
- An Introduction to Statistical Learning, James, Witten, Hastie and Tibshirani. Free. Chapter 10 covers neural networks, and chapter 5 covers cross-validation.
- Dive into Deep Learning. Free. Clear chapters on convolutional and recurrent networks, with diagrams.
- 3Blue1Brown: Neural networks. The best visual explanation of gradient descent and backpropagation.
- Roberts et al. (2017), Cross-validation strategies for data with temporal, spatial, hierarchical, or phylogenetic structure, Ecography 40: 913–929.