| SOCR ≫ | DSPA ≫ | DSPA3 Topics ≫ |
library(ggplot2)
library(dplyr)
library(tidyr)
library(patchwork)
library(plotly)
if (TORCH_OK) library(torch)
if (LUZ_OK) library(luz)Chapter 14 is published in five parts
Deep learning is the book’s largest single topic, and a single document would neither compile nor read well. Each part below stands alone and cross-links to the others.
Part Sections Content Part 1 (this document) §14.1–§14.20 Tensors, automatic differentiation, the MLP, activations, initialization, normalization, optimizers, regularization, and the training loop Part 2: Convolutional Networks and Vision §14.25–§14.44 Convolution arithmetic, receptive fields, residual connections, transfer learning, CIFAR-10, interpretability Part 3: Sequence Models: Recurrence and Attention §14.45–§14.64 RNNs, LSTM/GRU, attention derived, Transformers, tokenization, text generation, neural forecasting Part 4: Generative and Representation Learning §14.65–§14.84 Autoencoders, VAEs and the ELBO, GANs, diffusion, self-supervised learning Part 5: Generalization, Uncertainty, and Practice §14.85–§14.104 Double descent, calibration, ensembles, hyperparameter optimization, pruning, robustness Section numbers are continuous across all five parts, so a reference to §14.44 is unambiguous wherever you encounter it.
How this chapter uses graphics
Every two-dimensional figure is drawn with
ggplot2and rendered statically. Immediately after each one, the equivalentplot_ly()code appears in a chunk markedeval=FALSE, echo=TRUE.Every three-dimensional figure is drawn with
plot_ly()and evaluated, with a budget of six per part to control output size. Deep learning is full of genuinely two-parameter effects, activation variance over (depth, initialization gain), gradient magnitude over (depth, activation function), validation loss over (learning rate, batch size), and each of those is a surface whose shape is the point.
After completing Part 1 you will be able to:
torch’s autograd, and connect it
to the reverse-mode differentiation of Chapter 13,
§13.25.Estimated time: 10–14 hours including exercises. Prerequisites: Chapter 6 (perceptrons, backpropagation, §6.5), Chapter 9 (nested validation and calibration), Chapter 10 (memory hierarchy and arithmetic intensity, §10.14), and Chapter 13 (gradient methods, adaptive optimizers, automatic differentiation).
Before any code, one decision determines whether everything that follows is reproducible: which stack.
| Package | Status | Python needed? | Verdict |
|---|---|---|---|
torch |
Active; binds LibTorch directly | No | Primary stack for these five parts |
luz |
Active; high-level fit() over torch |
No | Used for the concise training loop |
keras3 |
Active; multi-backend Keras 3 | Yes, via reticulate |
Alternative, shown eval=FALSE |
tensorflow |
Active | Yes | Backend for keras3 |
reticulate |
Active | Yes | Python interop, when genuinely needed |
nnet |
Base-adjacent; one hidden layer | No | Fine for a shallow net; not deep learning |
neuralnet |
Maintained; small MLPs | No | Chapter 6 uses it |
h2o |
Active | No (needs Java) | Cluster-oriented; different use case |
mxnet / MXNetR |
Retired (Apache Attic, 2023) | — | Do not use |
darch, deepnet, rcppDL,
deepr |
Archived from CRAN | — | Do not use |
kerasR |
Superseded by
keras/keras3 |
Yes | Do not use |
The decision:
torch+luz. Rtorchbinds LibTorch, the same C++ library underlying PyTorch, and installs from CRAN, downloading a pre-compiled binary. It requires no Python interpreter, noreticulate, no conda or virtualenv, and supports CUDA and Apple Metal.That matters for more than convenience. A chapter whose code depends on a Python environment named
"r-tensorflow"existing on the reader’s machine is not reproducible; it is a set of instructions that happen to have worked somewhere. Everything in these five parts runs from a clean R installation.
keras3remains a reasonable choice, and where its idiom differs materially the equivalent appears in aneval=FALSEchunk. Note that Keras 3 is a multi-backend front end, it runs on TensorFlow, JAX, or PyTorch, so “Keras versus TensorFlow” is no longer the choice it once was.
Several frameworks that dominated earlier accounts of this subject are no longer live options: Theano ended development in 2017, Caffe2 merged into PyTorch in 2018, CNTK was archived by Microsoft in 2019, and Lua Torch was superseded by PyTorch in 2017. Of the classic list, TensorFlow/Keras and PyTorch are what remain.
A tensor is a multidimensional array bundled with three things an ordinary R array does not carry: a device, a dtype, and a gradient history.
\[ \begin{aligned} \textbf{rank 0: }&\ \text{scalar} & \textbf{rank 1: }&\ \text{vector }\in\mathbb R^{n}\\ \textbf{rank 2: }&\ \text{matrix }\in\mathbb R^{m\times n} & \textbf{rank 3: }&\ \text{e.g. }(\text{time},\text{batch},\text{feature})\\ \textbf{rank 4: }&\ (\text{batch},\text{channel},\text{height},\text{width}) & & \end{aligned} \]
Common misconception: “a tensor is just an array with more dimensions.” The extra dimensions are the least interesting part. What distinguishes a tensor in a deep-learning framework is that it records the operations performed on it, so that a gradient can be replayed backward through them — and that it carries a device, so the same code executes on CPU or GPU.
The practical consequence is that
x + yis not one operation but two: an arithmetic result and a node appended to a computational graph. That second effect is invisible until you callbackward(), and it is the entire mechanism of §14.2.
x <- torch_tensor(matrix(1:6, nrow = 2))
c(shape = paste(dim(x), collapse = " x "),
dtype = as.character(x$dtype),
device = as.character(x$device))#> shape dtype device
#> "2 x 3" "Long" "cpu"
# Broadcasting: shapes are aligned from the trailing dimension
a <- torch_randn(3, 1) # 3 x 1
b <- torch_randn(1, 4) # 1 x 4
c(broadcast_result = paste(dim(a + b), collapse = " x "))#> broadcast_result
#> "3 x 4"
# dtype is a memory and precision decision, not a formality
sizes <- data.frame(
dtype = c("float64", "float32", "float16"),
bytes_per_element = c(8, 4, 2),
MB_for_10M_elements = c(8, 4, 2) * 1e7 / 1e6,
note = c("R's default; rarely needed for training",
"the deep-learning standard",
"mixed precision; halves memory and doubles throughput"))
sizes
float32is the default for a reason. R usesdouble(float64) everywhere, and moving a model to float64 doubles both memory traffic and parameter storage while buying accuracy that gradient-based training does not use, the stochastic gradient noise of Chapter 13, §13.9 swamps the difference. Modern training goes the other way, to mixed precision: float16 for the forward and backward passes with a float32 master copy of the weights, which halves memory and roughly doubles throughput on hardware with tensor cores.
dev <- if (cuda_is_available()) {
"cuda"
} else if (tryCatch(backends_mps_is_available(), error = function(e) FALSE)) {
"mps"
} else {
"cpu" # Add a safe fallback default (e.g., CPU)
}
c(selected_device = dev)#> selected_device
#> "cpu"
n_bench <- 2000
A <- torch_randn(n_bench, n_bench)
t_cpu <- system.time(for (i in 1:3) A$matmul(A))[["elapsed"]] / 3
if (dev != "cpu") {
Ag <- A$to(device = dev)
invisible(Ag$matmul(Ag)) # warm up: first call compiles kernels
t_gpu <- system.time({ for (i in 1:3) Ag$matmul(Ag)
if (dev == "cuda") cuda_synchronize() })[["elapsed"]] / 3
t_move <- system.time(A$to(device = dev))[["elapsed"]]
data.frame(operation = c("matmul on CPU", paste("matmul on", dev), "host-to-device copy"),
seconds = round(c(t_cpu, t_gpu, t_move), 4),
gflops = round(c(2 * n_bench^3 / t_cpu, 2 * n_bench^3 / t_gpu, NA) / 1e9, 1))
} else {
data.frame(operation = "matmul on CPU", seconds = round(t_cpu, 4),
gflops = round(2 * n_bench^3 / t_cpu / 1e9, 1))
}Matrix multiplication has arithmetic intensity growing as \(O(n)\) (Chapter 10, §10.14.1), which puts it firmly in the compute-bound regime where a GPU’s advantage is real. Elementwise operations have intensity near \(0.1\) and are memory-bound on both devices, moving them to a GPU buys the bandwidth ratio, not the core-count ratio.
Chapter 13,
§13.25 established that reverse-mode automatic
differentiation computes a full gradient at a constant multiple of the
cost of one function evaluation, independent of the number of
parameters, and that backpropagation is reverse-mode AD
specialized to a layered graph. torch supplies exactly that
machinery.
\[\text{forward: build the graph}\quad\longrightarrow\quad\text{backward: accumulate }\frac{\partial\mathcal L}{\partial v}\text{ at every node}\]
# f(x, y) = sin(x*y) + exp(x); the same function differentiated by hand in
# Chapter 13's reverse-mode demonstration
x <- torch_tensor(1.1, requires_grad = TRUE)
y <- torch_tensor(2.3, requires_grad = TRUE)
f <- torch_sin(x * y) + torch_exp(x)
f$backward()
x1 <- 1.1; y1 <- 2.3
data.frame(
partial = c("df/dx", "df/dy"),
torch_autograd = signif(c(as.numeric(x$grad), as.numeric(y$grad)), 10),
analytic = signif(c(y1 * cos(x1 * y1) + exp(x1), x1 * cos(x1 * y1)), 10))One backward sweep produced both partials, and would produce all of them for a million-parameter model at the same cost.
# The graph is built by the forward pass and freed by backward() unless retained
w <- torch_randn(3, 2, requires_grad = TRUE)
b <- torch_zeros(2, requires_grad = TRUE)
inp <- torch_randn(5, 3)
out <- torch_relu(inp$matmul(w) + b)
loss <- out$sum()
c(loss_requires_grad = loss$requires_grad,
w_grad_before_backward = is.null(w$grad))#> loss_requires_grad w_grad_before_backward
#> TRUE FALSE
loss$backward()
c(w_grad_shape = paste(dim(w$grad), collapse = " x "),
b_grad_shape = paste(dim(b$grad), collapse = " x "))#> w_grad_shape b_grad_shape
#> "3 x 2" "2"
Gradients accumulate; they are not overwritten. Calling
backward()twice without clearing adds the second gradient to the first. Every training loop must therefore calloptimizer$zero_grad()(orzero_grad()on the module) before each backward pass. Omitting it is the single most common bug in hand-written training loops, and its symptom, a loss that decreases for a few steps and then diverges, looks exactly like a learning rate that is too large.The accumulation is deliberate: it is what makes gradient accumulation over micro-batches possible, letting a large effective batch size fit in limited memory.
z <- torch_tensor(2.0, requires_grad = TRUE)
(z^2)$backward(); g1 <- as.numeric(z$grad) # d(z^2)/dz = 2z = 4
(z^2)$backward(); g2 <- as.numeric(z$grad) # accumulates: 4 + 4 = 8
z$grad$zero_()#> torch_tensor
#> 0
#> [ CPUFloatType{1} ]
(z^2)$backward(); g3 <- as.numeric(z$grad) # cleared first: 4
c(first_backward = g1, second_backward_accumulated = g2,
after_zero_grad = g3, analytic = 4)#> first_backward second_backward_accumulated
#> 4 8
#> after_zero_grad analytic
#> 4 4
# Evaluation must not build a graph: with_no_grad() saves memory and time
m <- nn_linear(100, 100)
xin <- torch_randn(256, 100)
t_grad <- system.time(for (i in 1:50) m(xin))[["elapsed"]]
t_nograd <- system.time(with_no_grad({ for (i in 1:50) m(xin) }))[["elapsed"]]
c(with_graph_seconds = round(t_grad, 4),
no_grad_seconds = round(t_nograd, 4),
speedup = round(t_grad / t_nograd, 2))#> with_graph_seconds no_grad_seconds speedup
#> 0 0 NaN
Three quantities describe a network’s cost, and they scale differently.
For a fully connected layer mapping \(d_{\text{in}}\to d_{\text{out}}\) with batch size \(B\):
\[ \begin{aligned} \textbf{Parameters: }&\quad d_{\text{in}}d_{\text{out}}+d_{\text{out}}\\ \textbf{Forward FLOPs: }&\quad \approx 2Bd_{\text{in}}d_{\text{out}}\\ \textbf{Activation memory: }&\quad Bd_{\text{out}}\ \text{elements, \emph{retained} until the backward pass} \end{aligned} \]
Activation memory, not parameter memory, is usually the binding constraint. A network’s parameters are stored once; its activations are stored once per example in the batch, and every one of them must be kept alive from the forward pass until the corresponding backward step. Training also holds a gradient for every parameter, plus optimizer state, Adam keeps two moment estimates, so Adam triples the parameter memory relative to plain SGD.
The accounting for training is roughly \[\underbrace{4P}_{\text{weights}}+\underbrace{4P}_{\text{gradients}}+\underbrace{8P}_{\text{Adam moments}}+\underbrace{4\textstyle\sum_\ell Bd_\ell}_{\text{activations}}\ \text{bytes at float32},\] and the last term is the one that scales with batch size. This is why “reduce the batch size” is the standard response to an out-of-memory error, and why gradient checkpointing, recomputing activations in the backward pass instead of storing them, trades time for space.
count_params <- function(module) {
ps <- module$parameters
data.frame(
parameter = names(ps),
shape = vapply(ps, \(p) paste(dim(p), collapse = " x "), character(1)),
n = vapply(ps, \(p) prod(dim(p)), numeric(1)),
row.names = NULL)
}
mlp_demo <- nn_sequential(
nn_linear(64, 256), nn_relu(),
nn_linear(256, 128), nn_relu(),
nn_linear(128, 2))
pc <- count_params(mlp_demo)
pcc(total_parameters = sum(pc$n),
weight_memory_MB_float32 = round(4 * sum(pc$n) / 1e6, 3),
with_grads_and_adam_MB = round(16 * sum(pc$n) / 1e6, 3))#> total_parameters weight_memory_MB_float32 with_grads_and_adam_MB
#> 49794.000 0.199 0.797
# Activation memory grows with the batch; parameter memory does not
widths <- c(64, 256, 128, 2)
batch_sizes <- c(32, 256, 2048)
acc <- expand.grid(batch = batch_sizes) |>
mutate(activation_MB = 4 * batch * sum(widths[-1]) / 1e6,
parameter_MB = 4 * (64*256 + 256 + 256*128 + 128 + 128*2 + 2) / 1e6,
ratio = round(activation_MB / parameter_MB, 2))
acc |> mutate(across(where(is.numeric), \(z) round(z, 4)))At a batch of 32 the parameters dominate; by 2,048 the activations do. For the convolutional networks of Part 2, §14.25 the crossover happens much earlier, because a convolutional layer has few parameters and enormous activations.
The perceptron computes a weighted sum and thresholds it:
\[\hat y=\mathbb 1\big\{\mathbf w^\top\mathbf x+b>0\big\}.\]
Its decision boundary is the hyperplane \(\mathbf w^\top\mathbf x+b=0\), so it can represent exactly the linearly separable functions, and XOR is not one of them.
The biological analogy is history, not mechanism. The perceptron was inspired by a schematic account of a neuron: inputs weighted by synaptic strengths, summed, and fired above a threshold. That inspiration is real and worth knowing, and it explains essentially nothing about why deep networks work.
Biological neurons are spiking, stochastic, temporally coded, and locally plastic. Backpropagation requires a global, synchronous error signal propagated backward through the transpose of the forward weights, the “weight transport problem”, for which no biological mechanism is known. Throughout this chapter a network is treated as what it mathematically is: a composition of parameterized affine maps and pointwise nonlinearities, trained by gradient descent on a differentiable objective.
xor_x <- torch_tensor(matrix(c(0,0, 0,1, 1,0, 1,1), ncol = 2, byrow = TRUE))
xor_y <- torch_tensor(matrix(c(0, 1, 1, 0), ncol = 1))
fit_xor <- function(hidden = 0, epochs = 3000, lr = 0.1, seed = 1) {
torch_manual_seed(seed)
net <- if (hidden == 0) nn_linear(2, 1) else
nn_sequential(nn_linear(2, hidden), nn_tanh(), nn_linear(hidden, 1))
opt <- optim_adam(net$parameters, lr = lr)
for (e in seq_len(epochs)) {
opt$zero_grad()
loss <- nnf_binary_cross_entropy_with_logits(net(xor_x), xor_y)
loss$backward(); opt$step()
}
preds <- as.numeric(torch_sigmoid(net(xor_x)))
list(net = net, preds = preds,
accuracy = mean((preds > 0.5) == as.numeric(xor_y)))
}
r0 <- fit_xor(hidden = 0); r2 <- fit_xor(hidden = 2)
data.frame(
model = c("linear (no hidden layer)", "one hidden layer, 2 units"),
accuracy = c(r0$accuracy, r2$accuracy),
pred_00 = round(c(r0$preds[1], r2$preds[1]), 3),
pred_01 = round(c(r0$preds[2], r2$preds[2]), 3),
pred_10 = round(c(r0$preds[3], r2$preds[3]), 3),
pred_11 = round(c(r0$preds[4], r2$preds[4]), 3))The linear model converges to predicting \(0.5\) everywhere, the best a hyperplane can do on XOR. Two hidden units suffice, because they can construct a nonlinear coordinate change in which the classes are separable.
XOR shows that one hidden layer is sometimes necessary. NAND shows that it is always sufficient for Boolean logic.
\[\mathrm{NAND}(x_1,x_2)=\neg(x_1\wedge x_2)=\mathbb 1\big\{-x_1-x_2+1.5>0\big\}\]
so a single perceptron with weights \((-1,-1)\) and bias \(1.5\) computes it exactly, no hidden layer required.
NAND is functionally complete. Every Boolean function of any number of variables can be written using NAND alone: \(\neg a=\mathrm{NAND}(a,a)\), \(a\wedge b=\mathrm{NAND}(\mathrm{NAND}(a,b),\mathrm{NAND}(a,b))\), \(a\vee b=\mathrm{NAND}(\neg a,\neg b)\), and \(\{\neg,\wedge,\vee\}\) generates the rest.
Since one perceptron computes NAND, a network of perceptrons can compute any Boolean function, a discrete precursor of the universal approximation result of §14.6, and it carries the same warning. The construction says a representation exists; it says nothing about how many units it needs. Composing NAND gates to build an \(n\)-input parity function requires \(\Theta(n)\) gates in depth \(O(\log n)\), but a depth-2 circuit needs exponentially many, the Boolean analogue of the depth–width separation in §14.7.
# A single perceptron computes NAND exactly, with weights fixed by hand
nand_x <- torch_tensor(matrix(c(0,0, 0,1, 1,0, 1,1), ncol = 2, byrow = TRUE))
w_nand <- torch_tensor(matrix(c(-1, -1), ncol = 1)); b_nand <- torch_tensor(1.5)
nand_out <- as.numeric((nand_x$matmul(w_nand) + b_nand) > 0)
# XOR built from NAND gates: XOR(a,b) = NAND(NAND(a,NAND(a,b)), NAND(b,NAND(a,b)))
nand <- function(a, b) as.numeric(!(a & b))
xor_from_nand <- function(a, b) { t <- nand(a, b); nand(nand(a, t), nand(b, t)) }
data.frame(
x1 = c(0,0,1,1), x2 = c(0,1,0,1),
NAND_perceptron = nand_out,
NAND_truth = c(1,1,1,0),
XOR_from_4_NANDs = mapply(xor_from_nand, c(0,0,1,1), c(0,1,0,1)),
XOR_truth = c(0,1,1,0))Four NAND gates reproduce XOR exactly, the same function the single-layer network of §14.4 could not represent. Depth, not width, is what bought it.
The historical inspiration is real and worth stating once, precisely. The human brain has roughly \(10^{11}\) neurons and \(10^{15}\) synapses. A neuron integrates afferent input across its dendrites, and fires an action potential down its axon only when the aggregated membrane potential crosses a threshold. Synaptic strengths change with experience, and can be excitatory (\(w>0\)) or inhibitory (\(w<0\)). A perceptron’s weighted sum and threshold is a first-order rate-coding approximation of that: it models the frequency of firing and discards the timing of individual spikes.
Common misconception: “neural networks work because they simulate the brain.” The analogy explains where the vocabulary came from and almost nothing about why the method works.
Biological neurons are spiking, stochastic, temporally coded, and locally plastic, a synapse updates from signals available at that synapse. Backpropagation requires a global, synchronous error signal propagated backward through the transpose of the forward weights, and no biological mechanism is known that transports weights in this way. It is called the weight transport problem, and proposed biologically plausible alternatives — feedback alignment, predictive coding, remain research questions rather than settled accounts.
Treat a network as what it mathematically is: a composition of parameterized affine maps and pointwise nonlinearities, fitted by gradient descent on a differentiable objective. Every result in these five parts follows from that description, and none follows from neuroscience.
An \(L\)-layer network is a composition
\[\boxed{\;f(\mathbf x)=W^{(L)}\,\sigma\!\Big(W^{(L-1)}\sigma\big(\cdots\sigma(W^{(1)}\mathbf x+\mathbf b^{(1)})\cdots\big)+\mathbf b^{(L-1)}\Big)+\mathbf b^{(L)}\;}\]
with \(\sigma\) applied elementwise. The nonlinearity is what makes depth meaningful: without it the composition of affine maps \(W^{(L)}\cdots W^{(1)}\) is a single affine map, and a hundred layers collapse to one.
torch_manual_seed(7)
# A deep network with NO activation is exactly a linear map
deep_linear <- nn_sequential(nn_linear(5, 40), nn_linear(40, 40),
nn_linear(40, 40), nn_linear(40, 3))
xd <- torch_randn(100, 5)
Yd <- with_no_grad(deep_linear(xd))
# Recover the single equivalent affine map from the composed weights (layers 1 to 4)
Ws <- lapply(1:4, \(i) deep_linear[[i]]$weight)
bs <- lapply(1:4, \(i) deep_linear[[i]]$bias)
W_eq <- Ws[[4]]$matmul(Ws[[3]])$matmul(Ws[[2]])$matmul(Ws[[1]])
b_eq <- Ws[[4]]$matmul(Ws[[3]])$matmul(Ws[[2]])$matmul(bs[[1]]$unsqueeze(2))$squeeze() +
Ws[[4]]$matmul(Ws[[3]])$matmul(bs[[2]]$unsqueeze(2))$squeeze() +
Ws[[4]]$matmul(bs[[3]]$unsqueeze(2))$squeeze() + bs[[4]]
Y_eq <- xd$matmul(W_eq$t()) + b_eq
c(max_abs_difference = signif(as.numeric(torch_max(torch_abs(Yd - Y_eq))), 3),
conclusion = "four linear layers = one linear layer")#> max_abs_difference conclusion
#> "1.19e-07" "four linear layers = one linear layer"
Writing the same network unit by unit fixes the indices, which is worth doing once because conventions differ between references.
\[\boxed{\;a_k^{(\ell)}=f\!\left(\underbrace{\sum_{i}w_{k,i}^{(\ell)}\,a_i^{(\ell-1)}+b_k^{(\ell)}}_{z_k^{(\ell)}\ \text{(pre-activation)}}\right)\;}\]
\(W^{(\ell)}\) connects layer \(\ell-1\) to layer \(\ell\), and nothing else. Under the convention above, \(W^{(\ell)}\) has \(n_\ell\) rows (one per unit in the current layer) and \(n_{\ell-1}\) columns, so \(\mathbf z^{(\ell)}=W^{(\ell)}\mathbf a^{(\ell-1)}+\mathbf b^{(\ell)}\) is conformable. A statement that its columns index the next layer \((\ell+1)\) describes \(W^{(\ell+1)}\), and mixing the two makes every shape argument in a derivation wrong by one layer.
torch’snn_linear(in_features, out_features)storesweightwith shape \((\text{out},\text{in})\), matching the convention here, and computes \(\mathbf x W^\top+\mathbf b\) so that the batch dimension leads. Printdim()when in doubt; a silent transpose is a common source of shape errors that broadcast rather than error.
The bias is a learned intercept, not a residual. It shifts the decision boundary, exactly as the intercept does in linear regression (Chapter 3). It is not “information the model fails to explain”, that would be the residual \(y-\hat y\), which is a property of the fit, whereas \(b\) is a parameter the fit estimates.
net_s <- nn_sequential(nn_linear(7, 5), nn_tanh(), nn_linear(5, 3), nn_tanh(),
nn_linear(3, 2))
data.frame(
layer = c("W(1): 7 -> 5", "W(2): 5 -> 3", "W(3): 3 -> 2"),
weight_shape = c(paste(dim(net_s[[1]]$weight), collapse = " x "),
paste(dim(net_s[[3]]$weight), collapse = " x "),
paste(dim(net_s[[5]]$weight), collapse = " x ")),
rows_are = "units in the CURRENT layer",
columns_are = "units in the PREVIOUS layer")# The batch dimension leads, and shapes chain through
xb <- torch_randn(16, 7)
c(input = paste(dim(xb), collapse = " x "),
output = paste(dim(with_no_grad(net_s(xb))), collapse = " x "))#> input output
#> "16 x 7" "16 x 2"
# A fully connected 7-5-3-2 network, drawn to fix the notation above
layers_n <- c(7, 5, 3, 2)
nodes <- do.call(rbind, lapply(seq_along(layers_n), \(l)
data.frame(layer = l, unit = seq_len(layers_n[l]),
x = l, y = seq_len(layers_n[l]) - (layers_n[l] + 1)/2)))
edges <- do.call(rbind, lapply(seq_len(length(layers_n) - 1), function(l) {
a <- nodes[nodes$layer == l, ]; b <- nodes[nodes$layer == l + 1, ]
expand.grid(i = seq_len(nrow(a)), k = seq_len(nrow(b))) |>
mutate(x = a$x[i], y = a$y[i], xend = b$x[k], yend = b$y[k], layer = l + 1)
}))
nodes$role <- c("Input", "Hidden", "Hidden", "Output")[nodes$layer]
ggplot() +
geom_segment(data = edges, aes(x, y, xend = xend, yend = yend),
color = "grey78", linewidth = 0.25) +
geom_point(data = nodes, aes(x, y, color = role), size = 6) +
scale_color_manual(values = c(Input = "#7FB069", Hidden = "#9EC5E8",
Output = "#D8433B")) +
scale_x_continuous(breaks = 1:4,
labels = c("l = 1\ninput", "l = 2\nhidden",
"l = 3\nhidden", "l = 4\noutput")) +
labs(title = "A fully connected feed-forward network, 7-5-3-2",
subtitle = expression("Each edge carries a weight "*w[list(k,i)]^(l)*
"; each non-input node adds a bias "*b[k]^(l)),
x = NULL, y = NULL, color = NULL) +
theme_dspa(10) +
theme(axis.text.y = element_blank(), panel.grid = element_blank())# --- Interactive equivalent ------------------------------------------------
plot_ly() |>
add_segments(data = edges, x = ~x, y = ~y, xend = ~xend, yend = ~yend,
line = list(color = "lightgray", width = 1), showlegend = FALSE) |>
add_markers(data = nodes, x = ~x, y = ~y, color = ~role,
marker = list(size = 22),
text = ~paste0("a(", unit, ",", layer, ")"), hoverinfo = "text") |>
layout(title = "Fully connected feed-forward network",
xaxis = list(title = "Layer"), yaxis = list(title = ""))The final layer’s activation and the loss must be chosen together, because each loss presumes a particular output parameterization.
\[ \begin{aligned} \textbf{Regression: }&\ \text{identity output} &\ \mathcal L&=\tfrac1n\textstyle\sum_i(y_i-\hat y_i)^2\\[1mm] \textbf{Binary: }&\ \hat p=\sigma(z)=\tfrac{1}{1+e^{-z}} &\ \mathcal L&=-\tfrac1n\textstyle\sum_i\big[y_i\log\hat p_i+(1-y_i)\log(1-\hat p_i)\big]\\[1mm] \textbf{Multiclass: }&\ \hat p_j=\mathrm{softmax}(z)_j=\dfrac{e^{z_j}}{\sum_{m}e^{z_m}} &\ \mathcal L&=-\tfrac1n\textstyle\sum_i\log\hat p_{i,y_i} \end{aligned} \]
Cross-entropy is the negative log-likelihood of the corresponding distribution, Bernoulli for binary, categorical for multiclass, so minimizing it is maximum likelihood estimation (Chapter 12). It is also a strictly proper scoring rule (Chapter 9, §9.8.2), which is why it is minimized by reporting the true probability and why accuracy is not a training objective.
Use the fused
*_with_logitslosses; never compose sigmoid or softmax with a separate log. Computing \(\log\sigma(z)\) in two steps overflows for large \(|z|\): \(\sigma(z)\) rounds to exactly 0 or 1, and \(\log 0=-\infty\) produces aNaNthat propagates through the whole backward pass.The fused form uses the numerically stable identity \[\log\sigma(z)=-\log\big(1+e^{-z}\big)=-\max(-z,0)-\log\big(1+e^{-|z|}\big),\] and the softmax analogue subtracts \(\max_j z_j\) before exponentiating. In
torchthese arennf_binary_cross_entropy_with_logits()andnnf_cross_entropy(), and both take raw logits, not probabilities — passing an already-sigmoided value applies the nonlinearity twice.
z_big <- torch_tensor(c(-40, -10, 0, 10, 40))
y_tgt <- torch_tensor(c(0, 0, 1, 1, 1))
naive <- with_no_grad({
p <- torch_sigmoid(z_big)
-(y_tgt * torch_log(p) + (1 - y_tgt) * torch_log(1 - p))
})
fused <- with_no_grad(
nnf_binary_cross_entropy_with_logits(z_big, y_tgt, reduction = "none"))
data.frame(logit = as.numeric(z_big), target = as.numeric(y_tgt),
naive_two_step = signif(as.numeric(naive), 4),
fused_with_logits = signif(as.numeric(fused), 4))At \(|z|=40\) the two-step
computation returns Inf or NaN while the fused
form returns the correct finite value. This is the most common
source of a NaN loss on the very first backward
pass (§14.16).
logits <- torch_tensor(matrix(c(2.0, 1.0, 0.1,
1.0, 3.0, 0.2), nrow = 2, byrow = TRUE))
probs <- nnf_softmax(logits, dim = 2)
c(row_sums = paste(round(as.numeric(torch_sum(probs, dim = 2)), 6), collapse = ", "),
note = "softmax outputs a probability vector per row")#> row_sums
#> "1, 1"
#> note
#> "softmax outputs a probability vector per row"
# Softmax is shift-invariant: subtracting max_j z_j changes nothing but prevents overflow
shifted <- nnf_softmax(logits - torch_max(logits), dim = 2)
c(max_abs_difference = signif(as.numeric(torch_max(torch_abs(probs - shifted))), 3))#> max_abs_difference
#> 7.45e-09
Universal approximation theorem (Cybenko 1989; Hornik 1991). Let \(\sigma\) be a non-polynomial continuous function. For any continuous \(f\) on a compact \(K\subset\mathbb R^d\) and any \(\varepsilon>0\), there exist \(N\), weights \(\mathbf w_i\), biases \(b_i\), and coefficients \(\alpha_i\) with \[\sup_{\mathbf x\in K}\left|f(\mathbf x)-\sum_{i=1}^{N}\alpha_i\,\sigma\big(\mathbf w_i^\top\mathbf x+b_i\big)\right|<\varepsilon.\]
Common misconception: “universal approximation means a neural network can learn any function.” The theorem is an existence result about representation. It makes no claim about any of the four things that determine whether a network is useful.
It does not bound \(N\). The required width can grow exponentially in the input dimension, approximating a general Lipschitz function on \([0,1]^d\) to accuracy \(\varepsilon\) needs \(N=\Omega(\varepsilon^{-d})\) units. The theorem is satisfied by networks too large to instantiate.
It says nothing about learnability. Weights that achieve the approximation exist; nothing says gradient descent finds them, and the objective is non-convex (Chapter 13, §13.14).
It says nothing about generalization. Approximation is measured on \(K\) with \(f\) known. A finite training sample constrains the function only where data exist.
It is not special to neural networks. Polynomials (Stone–Weierstrass), splines, Fourier series, and kernel machines are all universal approximators. Universality is a low bar; it is not why deep learning works.
# A one-hidden-layer network CAN approximate a wiggly 1-D function -- with
# enough units. Watch how many it takes.
set.seed(11); torch_manual_seed(11)
f_target <- function(x) sin(3 * x) + 0.4 * sin(11 * x)
xs <- seq(-3, 3, length.out = 400)
xt <- torch_tensor(matrix(xs, ncol = 1))
yt <- torch_tensor(matrix(f_target(xs), ncol = 1))
fit_width <- function(w, epochs = 2500) {
torch_manual_seed(11)
net <- nn_sequential(nn_linear(1, w), nn_tanh(), nn_linear(w, 1))
opt <- optim_adam(net$parameters, lr = 0.02)
for (e in seq_len(epochs)) {
opt$zero_grad(); l <- nnf_mse_loss(net(xt), yt); l$backward(); opt$step()
}
list(pred = as.numeric(with_no_grad(net(xt))),
mse = as.numeric(with_no_grad(nnf_mse_loss(net(xt), yt))))
}
widths_try <- c(2, 8, 32, 128)
uw <- lapply(widths_try, fit_width)
data.frame(hidden_units = widths_try,
parameters = 3 * widths_try + 1,
final_mse = signif(vapply(uw, \(z) z$mse, numeric(1)), 4))bind_rows(lapply(seq_along(widths_try), \(i)
data.frame(x = xs, y = uw[[i]]$pred,
w = sprintf("%d hidden units", widths_try[i])))) |>
mutate(w = factor(w, levels = sprintf("%d hidden units", widths_try))) |>
ggplot(aes(x, y)) +
geom_line(data = data.frame(x = xs, y = f_target(xs)), aes(x, y),
color = "grey35", linetype = "dashed", linewidth = 0.7) +
geom_line(color = "#3B7DD8", linewidth = 0.9) +
facet_wrap(~ w) +
labs(title = "Universal approximation is about existence, and existence needs width",
subtitle = "Dashed: the target. One hidden layer, tanh activation, identical training budget",
x = "x", y = "f(x)") +
theme_dspa(10)If one hidden layer suffices in principle, why build deep networks?
Depth buys exponential efficiency. For ReLU networks, the number of linear regions a network can carve out of its input space grows polynomially in width but exponentially in depth (Montúfar et al., 2014). A network of depth \(L\) and width \(w\) on \(d\) inputs realizes on the order of \[\Big(\tfrac{w}{d}\Big)^{d(L-1)}w^{d}\quad\text{regions},\] so adding a layer multiplies the count while adding units merely raises a base.
There are functions computable by a depth-\(k\) network with polynomially many units that require exponentially many units at depth \(k-1\) (Telgarsky, 2016). Depth is not a convenience, it is a representational resource with no shallow substitute.
The countervailing fact is that depth makes optimization harder: gradients traverse more factors (§14.8), which is what initialization (§14.9), normalization (§14.10), and residual connections (Part 2, §14.28) exist to repair.
# Count linear regions empirically for 1-D ReLU networks of equal parameter count
count_regions <- function(depth, width, n_grid = 20000, seed = 3) {
torch_manual_seed(seed)
layers <- list(nn_linear(1, width), nn_relu())
if (depth > 1) for (i in 2:depth) layers <- c(layers, list(nn_linear(width, width), nn_relu()))
layers <- c(layers, list(nn_linear(width, 1)))
net <- do.call(nn_sequential, layers)
xg <- torch_tensor(matrix(seq(-4, 4, length.out = n_grid), ncol = 1))
yg <- as.numeric(with_no_grad(net(xg)))
slope <- diff(yg)
# A new linear region begins wherever the slope changes appreciably
sum(abs(diff(slope)) > 1e-9) + 1
}
data.frame(
architecture = c("depth 1, width 24", "depth 2, width 12",
"depth 3, width 8", "depth 4, width 6"),
depth = c(1, 2, 3, 4), width = c(24, 12, 8, 6),
linear_regions = c(count_regions(1, 24), count_regions(2, 12),
count_regions(3, 8), count_regions(4, 6)))Roughly matched parameter budgets, very different expressive capacity, the deeper networks partition the input into far more linear pieces.
Sections 14.8–14.13 are, collectively, the answer to a single question: why does a network that is expressive enough often fail to train?
\[ \begin{aligned} \textbf{Sigmoid: }&\ \sigma(z)=\tfrac{1}{1+e^{-z}}, &\ \sigma'(z)&=\sigma(z)\big(1-\sigma(z)\big)\ \le\ \tfrac14\\ \textbf{Tanh: }&\ \tanh(z), &\ \tanh'(z)&=1-\tanh^2(z)\ \le\ 1\\ \textbf{ReLU: }&\ \max(0,z), &\ \tfrac{d}{dz}&=\mathbb 1\{z>0\}\\ \textbf{Leaky ReLU: }&\ \max(\alpha z,z), &\ \tfrac{d}{dz}&=\alpha\ \text{or}\ 1\\ \textbf{GELU: }&\ z\,\Phi(z), &\ \tfrac{d}{dz}&=\Phi(z)+z\phi(z)\\ \textbf{SiLU/Swish: }&\ z\,\sigma(z), &\ \tfrac{d}{dz}&=\sigma(z)\big(1+z(1-\sigma(z))\big) \end{aligned} \]
The derivative bounds are the whole story. Backpropagation through \(L\) layers multiplies \(L\) Jacobians (Chapter 12, §12.26.1), and each carries a factor of \(\sigma'\). With sigmoid, \(\sigma'\le\frac14\), so the gradient shrinks by at least \(4^{-L}\), at \(L=10\) that is \(10^{-6}\) before the weights are even considered. This single bound is why sigmoid activations made deep networks untrainable for two decades.
Common misconception: “ReLU solves the vanishing-gradient problem.” It removes the saturation factor, \(\sigma'=1\) on the positive side, so no shrinkage there, and introduces a different pathology.
A ReLU unit whose pre-activation is negative for every training example has gradient exactly zero, receives no update, and stays that way forever. It is a dead unit, and a large learning rate can kill a substantial fraction of a layer in a single step. Leaky ReLU (\(\alpha\approx0.01\)), GELU, and SiLU all keep a small negative-side gradient for exactly this reason.
ReLU also does not remove the weight contribution to gradient scaling. The product \(\prod_\ell W^{(\ell)\top}\) still explodes or vanishes unless the weights are scaled correctly, which is what §14.9 is for.
zs <- seq(-4, 4, length.out = 500)
act <- bind_rows(
data.frame(z = zs, v = 1/(1+exp(-zs)), d = (1/(1+exp(-zs)))*(1-1/(1+exp(-zs))), a = "Sigmoid"),
data.frame(z = zs, v = tanh(zs), d = 1 - tanh(zs)^2, a = "Tanh"),
data.frame(z = zs, v = pmax(0, zs), d = as.numeric(zs > 0), a = "ReLU"),
data.frame(z = zs, v = zs * pnorm(zs), d = pnorm(zs) + zs * dnorm(zs), a = "GELU"),
data.frame(z = zs, v = zs/(1+exp(-zs)),
d = (1/(1+exp(-zs)))*(1 + zs*(1-1/(1+exp(-zs)))), a = "SiLU"))
p_v <- ggplot(act, aes(z, v, color = a)) + geom_line(linewidth = 0.9) +
scale_color_viridis_d(option = "turbo", end = 0.9) +
labs(title = "Activation functions", x = NULL, y = expression(sigma(z)), color = NULL) +
theme_dspa(9)
p_d <- ggplot(act, aes(z, d, color = a)) +
geom_hline(yintercept = 0.25, linetype = "dotted", color = "grey40") +
geom_line(linewidth = 0.9) +
annotate("text", x = 3.4, y = 0.31, label = "sigmoid ceiling 1/4",
size = 3, color = "grey35") +
scale_color_viridis_d(option = "turbo", end = 0.9, guide = "none") +
labs(title = "Their derivatives -- the factor multiplied at every layer",
x = "z", y = expression(sigma*"'"*(z))) +
theme_dspa(9)
p_v / p_d# --- Interactive equivalent ------------------------------------------------
plot_ly(act, x = ~z, y = ~d, color = ~a, type = "scatter", mode = "lines") |>
add_lines(x = range(zs), y = c(0.25, 0.25), name = "sigmoid ceiling",
line = list(dash = "dot", color = "grey")) |>
layout(title = "Activation derivatives",
xaxis = list(title = "z"), yaxis = list(title = "derivative"))# Dying ReLU, measured: fraction of units with zero gradient after training
count_dead <- function(lr, seed = 21, steps = 400, width = 256) {
torch_manual_seed(seed); set.seed(seed)
net <- nn_sequential(nn_linear(20, width), nn_relu(), nn_linear(width, 1))
opt <- optim_sgd(net$parameters, lr = lr)
X <- torch_randn(512, 20); Y <- torch_randn(512, 1)
for (s in seq_len(steps)) {
opt$zero_grad(); l <- nnf_mse_loss(net(X), Y); l$backward(); opt$step()
}
h <- with_no_grad(torch_relu(net[[1]](X)))
as.numeric(torch_mean((torch_sum(h > 0, dim = 1) == 0)$to(dtype = torch_float())))
}
data.frame(learning_rate = c(0.01, 0.1, 0.5, 1.0),
fraction_dead_units = vapply(c(0.01, 0.1, 0.5, 1.0), count_dead, numeric(1)))The fraction of permanently dead units rises sharply with the learning rate. Those units are not slow, they are gone, and no subsequent training recovers them.
# Gradient magnitude reaching layer 1, as a function of depth and activation
grad_at_depth <- function(depth, act_fn, width = 48, seed = 5) {
torch_manual_seed(seed)
layers <- list(nn_linear(width, width), act_fn())
if (depth > 1) for (i in 2:depth) layers <- c(layers, list(nn_linear(width, width), act_fn()))
layers <- c(layers, list(nn_linear(width, 1)))
net <- do.call(nn_sequential, layers)
X <- torch_randn(64, width)
out <- net(X)$sum(); out$backward()
as.numeric(torch_norm(net[[1]]$weight$grad))
}
depths <- 1:12
acts <- list(Sigmoid = nn_sigmoid, Tanh = nn_tanh, ReLU = nn_relu, GELU = nn_gelu)
Zg <- t(vapply(names(acts), function(a)
vapply(depths, \(d) grad_at_depth(d, acts[[a]]), numeric(1)), numeric(length(depths))))
plot_ly(x = depths, y = names(acts), z = log10(pmax(Zg, 1e-30)), type = "surface",
colorscale = "Viridis",
colorbar = list(title = "log10 gradient\nnorm at layer 1")) |>
layout(title = "Gradient reaching the first layer, by depth and activation",
scene = list(xaxis = list(title = "Hidden layers"),
yaxis = list(title = "Activation"),
zaxis = list(title = "log10 ||grad||")))Rotate along the depth axis. The sigmoid surface falls away steeply, that is \(4^{-L}\) made visible. ReLU and GELU stay far flatter, which is why they made depth practical.
Common misconception: “initialization is just where you start; training fixes it.” For a deep network, initialization determines whether training happens at all. The wrong scale makes activations vanish or explode geometrically with depth, and a network whose forward signal has already collapsed to zero by layer 20 has no gradient to descend.
The derivation. Consider layer \(\ell\) with \(n_{\text{in}}\) inputs, weights drawn i.i.d. with mean 0 and variance \(\operatorname{Var}(W)\), and inputs with variance \(\operatorname{Var}(a^{(\ell-1)})\). For the pre-activation \(z_i=\sum_{j=1}^{n_{\text{in}}}W_{ij}a_j\),
\[\operatorname{Var}(z)=n_{\text{in}}\operatorname{Var}(W)\operatorname{Var}(a^{(\ell-1)}).\]
To keep the variance constant across layers we need \(n_{\text{in}}\operatorname{Var}(W)=1\). Propagating gradients backward gives the same condition with \(n_{\text{out}}\). Two standard resolutions:
\[ \begin{aligned} \textbf{Xavier/Glorot }(\tanh,\ \text{sigmoid}):&\quad \operatorname{Var}(W)=\frac{2}{n_{\text{in}}+n_{\text{out}}}\\[2mm] \textbf{He/Kaiming }(\text{ReLU}):&\quad \operatorname{Var}(W)=\frac{2}{n_{\text{in}}} \end{aligned} \]
Why He carries the extra factor of 2. ReLU zeroes half its inputs in expectation for a symmetric pre-activation distribution, so \(\operatorname{Var}(\mathrm{ReLU}(z))=\tfrac12\operatorname{Var}(z)\). Doubling the weight variance exactly compensates for that halving.
propagate_variance <- function(gain, depth = 30, width = 256, act = nn_relu,
seed = 31) {
torch_manual_seed(seed)
h <- torch_randn(512, width)
vars <- numeric(depth)
a <- act()
for (l in seq_len(depth)) {
W <- torch_randn(width, width) * sqrt(gain / width)
h <- a(h$matmul(W$t()))
vars[l] <- as.numeric(torch_var(h))
}
vars
}
gains <- c(0.5, 1, 2, 4) # gain = 2 is He initialization for ReLU
iv <- bind_rows(lapply(gains, \(g)
data.frame(layer = 1:30, variance = pmax(propagate_variance(g), 1e-30),
gain = sprintf("gain = %.1f%s", g, ifelse(g == 2, " (He)", "")))))
ggplot(iv, aes(layer, variance, color = gain)) +
geom_hline(yintercept = 1, linetype = "dashed", color = "grey40") +
geom_line(linewidth = 1) +
scale_y_log10() +
scale_color_viridis_d(option = "plasma", end = 0.9) +
labs(title = "Activation variance through 30 ReLU layers",
subtitle = "Dashed: variance preserved. Only gain = 2 (He) holds it; the others vanish or explode geometrically",
x = "Layer", y = "Activation variance (log scale)", color = NULL) +
theme_dspa()gain_grid <- seq(0.25, 4.5, length.out = 40)
depth_grid <- 1:30
Zi <- t(vapply(gain_grid, \(g) log10(pmax(propagate_variance(g, depth = 30), 1e-30)),
numeric(30)))
plot_ly(x = depth_grid, y = gain_grid, z = Zi, type = "surface",
colorscale = "RdBu",
colorbar = list(title = "log10\nvariance")) |>
add_surface(x = depth_grid, y = gain_grid,
z = matrix(0, length(gain_grid), length(depth_grid)),
opacity = 0.3, showscale = FALSE,
colorscale = list(c(0, "black"), c(1, "black"))) |>
layout(title = "Activation variance over depth and initialization gain; the flat plane is variance 1",
scene = list(xaxis = list(title = "Layer depth"),
yaxis = list(title = "Initialization gain"),
zaxis = list(title = "log10 variance", range = c(-12, 8))))The flat black plane marks preserved variance. The colored surface crosses it along a single ridge at gain 2, the He value, and falls away exponentially on both sides. Rotate to the far edge: by layer 30 the difference between gain 1 and gain 2 is many orders of magnitude.
# Does the initialization scale actually decide whether the network trains?
train_deep <- function(gain, depth = 20, width = 128, steps = 300, seed = 41) {
torch_manual_seed(seed); set.seed(seed)
X <- torch_randn(512, width); Y <- torch_randn(512, 1)
layers <- list()
for (l in seq_len(depth)) layers <- c(layers, list(nn_linear(width, width), nn_relu()))
layers <- c(layers, list(nn_linear(width, 1)))
net <- do.call(nn_sequential, layers)
for (m in net$modules) if (inherits(m, "nn_linear")) {
with_no_grad({ m$weight$normal_(0, sqrt(gain / width)); m$bias$zero_() })
}
opt <- optim_sgd(net$parameters, lr = 0.01)
l0 <- as.numeric(with_no_grad(nnf_mse_loss(net(X), Y)))
for (s in seq_len(steps)) {
opt$zero_grad(); l <- nnf_mse_loss(net(X), Y); l$backward(); opt$step()
}
lf <- as.numeric(with_no_grad(nnf_mse_loss(net(X), Y)))
c(initial_loss = l0, final_loss = lf, reduction = 1 - lf/l0)
}
as.data.frame(do.call(rbind, lapply(c(0.5, 1, 2, 4), function(g)
c(gain = g, train_deep(g))))) |>
mutate(across(everything(), \(z) signif(z, 4)))At gain 0.5 the 20-layer network barely moves, the forward signal has already collapsed. The architecture is identical in every row; only the initial weight scale differs.
# torch's built-in initializers, and the fan values they use
lin <- nn_linear(64, 32)
fan_in <- 64; fan_out <- 32
data.frame(
scheme = c("Xavier uniform", "Xavier normal", "He (Kaiming) normal, ReLU",
"torch nn_linear default"),
weight_sd_or_bound = signif(c(
sqrt(6 / (fan_in + fan_out)), # uniform bound
sqrt(2 / (fan_in + fan_out)), # normal sd
sqrt(2 / fan_in), # He normal sd
as.numeric(torch_std(lin$weight))), 4),
note = c("bound of U(-b, b)", "sd of N(0, sd^2)", "sd of N(0, sd^2)",
"empirical sd of the default"))Batch normalization standardizes each feature across the batch, then applies a learned affine map:
\[\hat z_i=\frac{z_i-\mu_{\mathcal B}}{\sqrt{\sigma^2_{\mathcal B}+\epsilon}}, \qquad y_i=\gamma\hat z_i+\beta,\]
with \(\mu_{\mathcal B},\sigma^2_{\mathcal B}\) computed over the batch dimension, and \(\gamma,\beta\) learned per feature.
Layer normalization standardizes across the feature dimension for each example independently, no batch statistics at all.
\[ \begin{aligned} \textbf{BatchNorm: }&\ \text{normalize over }(\text{batch}) \ \text{per feature} &&\text{batch-dependent; needs running statistics at test time}\\ \textbf{LayerNorm: }&\ \text{normalize over }(\text{features}) \ \text{per example} &&\text{batch-independent; identical at train and test} \end{aligned} \]
Common misconception: “batch normalization works by reducing internal covariate shift.” That was the original explanation, and the evidence against it is direct: deliberately injecting covariate shift after a BatchNorm layer does not degrade training, and networks with badly shifting internal distributions train fine when BatchNorm is present (Santurkar et al., 2018).
The better-supported account is that normalization smooths the loss landscape, it reduces the Lipschitz constant of the loss and of its gradient, which permits larger stable learning rates (Chapter 13, §13.5.1: the bound is \(\eta<2/L\), and normalization lowers \(L\)).
Two practical consequences follow, and both matter. BatchNorm couples the examples in a batch: an individual prediction depends on the other examples it happened to be batched with, which is why it degrades at small batch sizes and why it needs separate running statistics at test time. And it is the reason Transformers use LayerNorm instead (Part 3, §14.45) — sequence models have variable lengths and often tiny batches.
torch_manual_seed(51)
z <- torch_randn(8, 6) * 4 + 10 # badly scaled activations
bn <- nn_batch_norm1d(6); ln <- nn_layer_norm(6)
zb <- bn(z); zl <- ln(z)
data.frame(
tensor = c("input", "after BatchNorm", "after LayerNorm"),
mean_over_batch = signif(c(as.numeric(torch_mean(z)),
as.numeric(torch_mean(zb)),
as.numeric(torch_mean(zl))), 3),
sd_per_feature = signif(c(as.numeric(torch_mean(torch_std(z, dim = 1))),
as.numeric(torch_mean(torch_std(zb, dim = 1))),
as.numeric(torch_mean(torch_std(zl, dim = 1)))), 3),
sd_per_example = signif(c(as.numeric(torch_mean(torch_std(z, dim = 2))),
as.numeric(torch_mean(torch_std(zb, dim = 2))),
as.numeric(torch_mean(torch_std(zl, dim = 2)))), 3))BatchNorm drives the per-feature standard deviation to 1; LayerNorm drives the per-example one. They normalize orthogonal directions of the same matrix.
# BatchNorm's batch statistics are noisy at small batch sizes
bn_noise <- function(bs, reps = 200, feat = 32, seed = 53) {
torch_manual_seed(seed)
full <- torch_randn(4096, feat)
pop_var <- as.numeric(torch_var(full, dim = 1)$mean())
est <- vapply(seq_len(reps), function(r) {
idx <- sample(4096, bs)
as.numeric(torch_var(full[idx, ], dim = 1)$mean())
}, numeric(1))
c(batch_size = bs, relative_sd = sd(est) / pop_var)
}
bnz <- as.data.frame(do.call(rbind, lapply(c(2, 4, 8, 16, 32, 128, 512), bn_noise)))
bnz |> mutate(relative_sd = round(relative_sd, 4))ggplot(bnz, aes(batch_size, relative_sd)) +
geom_line(linewidth = 1, color = "steelblue") + geom_point(size = 2.4) +
scale_x_log10(breaks = bnz$batch_size) +
labs(title = "BatchNorm's variance estimate is noisy at small batch sizes",
subtitle = "Relative SD of the batch variance estimate. Below ~16 the normalization itself becomes a source of noise",
x = "Batch size (log scale)", y = "Relative SD of batch variance") +
theme_dspa()Chapter 13, §13.8 derived AdaGrad, RMSProp, and Adam and identified them as diagonal preconditioners. Two practical points that theory does not settle.
Adam converges faster early and often generalizes worse than well-tuned SGD with momentum on vision tasks. The adaptive scaling that speeds up early progress also shrinks the effective step in high-curvature directions, changing which minimum the trajectory settles into. In practice: SGD+momentum for convolutional vision models, Adam/AdamW for Transformers and anything with sparse or badly scaled gradients.
Common misconception: “adding an \(L_2\) penalty and setting
weight_decayare the same thing.” For plain SGD they are equivalent. For Adam they are not, and the difference is large enough to matter.Adding \(\frac\lambda2\|\theta\|^2\) to the loss puts \(\lambda\theta\) into the gradient, which then passes through Adam’s adaptive rescaling \(\hat m/(\sqrt{\hat v}+\epsilon)\). Parameters with historically large gradients get a smaller effective decay, and parameters with small gradients get a larger one, so the regularization strength varies per parameter in a way nobody intended.
AdamW (Loshchilov & Hutter, 2019) decouples the decay, applying it directly to the weights after the adaptive step: \[\theta_{t+1}=\theta_t-\eta\left(\frac{\hat m_t}{\sqrt{\hat v_t}+\epsilon}+\lambda\theta_t\right)\] so every parameter decays at the same rate. This is the default for Transformer training, and it is a genuine algorithmic difference, not a reparameterization.
# L2-in-the-loss under Adam vs decoupled decay under AdamW, on identical setups
compare_decay <- function(method, lambda = 0.05, steps = 400, seed = 61) {
torch_manual_seed(seed); set.seed(seed)
X <- torch_randn(256, 20); Y <- torch_randn(256, 1)
net <- nn_sequential(nn_linear(20, 64), nn_relu(), nn_linear(64, 1))
opt <- switch(method,
adam_l2 = optim_adam(net$parameters, lr = 0.01),
adam_wd = optim_adam(net$parameters, lr = 0.01, weight_decay = lambda),
adamw = optim_adamw(net$parameters, lr = 0.01, weight_decay = lambda))
for (s in seq_len(steps)) {
opt$zero_grad()
l <- nnf_mse_loss(net(X), Y)
if (method == "adam_l2") {
pen <- Reduce(`+`, lapply(net$parameters, \(p) torch_sum(p^2)))
l <- l + (lambda / 2) * pen
}
l$backward(); opt$step()
}
norms <- vapply(net$parameters, \(p) as.numeric(torch_norm(p)), numeric(1))
c(final_mse = as.numeric(with_no_grad(nnf_mse_loss(net(X), Y))),
total_weight_norm = sqrt(sum(norms^2)))
}
as.data.frame(do.call(rbind, lapply(
c("adam_l2", "adam_wd", "adamw"), \(m) c(method = m, signif(compare_decay(m), 4)))))The three rows use the same \(\lambda\), the same learning rate, the same seed, and the same architecture, and reach different weight norms, because the decay is being applied at different points in the update.
The learning rate is the most consequential hyperparameter, and holding it constant is rarely optimal.
\[ \begin{aligned} \textbf{Step decay: }&\ \eta_t=\eta_0\gamma^{\lfloor t/s\rfloor}\\ \textbf{Cosine annealing: }&\ \eta_t=\eta_{\min}+\tfrac12(\eta_0-\eta_{\min})\big(1+\cos(\pi t/T)\big)\\ \textbf{Linear warmup: }&\ \eta_t=\eta_0\cdot\min\!\big(1,\ t/T_{\text{warm}}\big) \end{aligned} \]
Warmup exists because of adaptive optimizers, not because of the model. Adam’s second-moment estimate \(\hat v_t\) is built from very few samples in the first steps, so \(1/\sqrt{\hat v_t}\) is both large and badly estimated, the effective step size early in training has enormous variance. Ramping \(\eta\) from near zero lets the moment estimates stabilize before full-size steps are taken. It is essential for Transformers, and largely unnecessary for SGD+momentum.
Tt <- 1000; eta0 <- 1e-3
sched <- data.frame(t = 1:Tt) |>
mutate(Constant = eta0,
`Step decay` = eta0 * 0.5^floor(t / 250),
`Cosine` = 1e-5 + 0.5 * (eta0 - 1e-5) * (1 + cos(pi * t / Tt)),
`Warmup + cosine` = ifelse(t < 100, eta0 * t / 100,
1e-5 + 0.5*(eta0-1e-5)*(1+cos(pi*(t-100)/(Tt-100))))) |>
pivot_longer(-t, names_to = "schedule", values_to = "lr")
ggplot(sched, aes(t, lr, color = schedule)) +
geom_line(linewidth = 0.9) +
scale_color_viridis_d(option = "turbo", end = 0.9) +
labs(title = "Learning-rate schedules",
subtitle = "Warmup ramps up so adaptive moment estimates can stabilize; cosine anneals for a fine final approach",
x = "Step", y = "Learning rate", color = NULL) +
theme_dspa()# The learning rate and batch size interact; neither can be tuned alone
set.seed(71); torch_manual_seed(71)
n_lb <- 2048; p_lb <- 20
Xlb <- torch_randn(n_lb, p_lb)
beta_lb <- torch_randn(p_lb, 1)
Ylb <- Xlb$matmul(beta_lb) + torch_randn(n_lb, 1) * 0.5
Xval <- torch_randn(512, p_lb); Yval <- Xval$matmul(beta_lb) + torch_randn(512, 1) * 0.5
train_lb <- function(lr, bs, epochs = 12, seed = 73) {
torch_manual_seed(seed)
net <- nn_sequential(nn_linear(p_lb, 64), nn_relu(), nn_linear(64, 1))
opt <- optim_sgd(net$parameters, lr = lr, momentum = 0.9)
for (e in seq_len(epochs)) {
perm <- sample(n_lb)
for (s in seq(1, n_lb - bs + 1, by = bs)) {
idx <- perm[s:(s + bs - 1)]
opt$zero_grad()
l <- nnf_mse_loss(net(Xlb[idx, ]), Ylb[idx, ])
l$backward(); opt$step()
}
}
v <- as.numeric(with_no_grad(nnf_mse_loss(net(Xval), Yval)))
if (!is.finite(v)) 1e3 else min(v, 1e3)
}
lrs <- 10^seq(-3, 0, length.out = 12)
bss <- c(8, 16, 32, 64, 128, 256)
Zlb <- outer(bss, lrs, Vectorize(function(b, l) log10(train_lb(l, b))))
plot_ly(x = lrs, y = bss, z = Zlb, type = "surface",
colorscale = "Inferno", reversescale = TRUE,
colorbar = list(title = "log10\nval MSE")) |>
layout(title = "Validation loss over learning rate and batch size",
scene = list(xaxis = list(title = "Learning rate", type = "log"),
yaxis = list(title = "Batch size", type = "log"),
zaxis = list(title = "log10 validation MSE")))The good region is a diagonal valley, not a rectangle: larger batches tolerate, and require, larger learning rates, because the gradient estimate is less noisy. Tuning \(\eta\) at one batch size and then changing the batch size moves you off the valley floor. The common heuristic is to scale \(\eta\) linearly with batch size, which is the direction this surface’s valley runs.
\[ \begin{aligned} \textbf{Weight decay: }&\ \text{shrink }\theta\text{ toward }0\ \text{— see \S14.11.2 for the Adam caveat}\\ \textbf{Dropout: }&\ \text{zero each unit independently with probability }p\ \text{at \emph{training} time}\\ \textbf{Early stopping: }&\ \text{halt when validation loss stops improving}\\ \textbf{Augmentation: }&\ \text{apply label-preserving transformations to the inputs} \end{aligned} \]
Dropout behaves differently at training and test time, and the scaling is easy to get wrong. During training, units are zeroed with probability \(p\) and the survivors are scaled by \(1/(1-p)\) (“inverted dropout”) so the expected activation is unchanged. At test time dropout is off and no scaling is applied.
Forgetting to switch modes is a classic bug with a distinctive signature: validation loss higher than training loss and unstable across evaluations, because the model is still dropping units while being scored. In
torchthe switch ismodel$eval()andmodel$train(); in a hand-written loop it is easy to omit.Conceptually dropout approximates training an exponentially large ensemble of subnetworks with shared weights, and averaging them at test time. That is also why it interacts poorly with BatchNorm, the two disagree about what the activation statistics should be, and why modern architectures often use one or the other rather than both.
torch_manual_seed(81)
drop <- nn_dropout(p = 0.5)
v <- torch_ones(1, 10)
drop$train()
train_out <- as.numeric(drop(v))
drop$eval()
eval_out <- as.numeric(drop(v))
data.frame(
mode = c("train()", "eval()"),
n_zeroed = c(sum(train_out == 0), sum(eval_out == 0)),
value_of_survivors = c(unique(train_out[train_out != 0])[1], unique(eval_out)[1]),
mean_output = c(mean(train_out), mean(eval_out)),
note = c("survivors scaled by 1/(1-p) = 2", "identity: no dropping, no scaling"))# A deliberately overparameterized network on a small sample
set.seed(91); torch_manual_seed(91)
n_r <- 200; p_r <- 30
Xr <- torch_randn(n_r, p_r)
br <- torch_zeros(p_r, 1); br[1:5, 1] <- 2
Yr <- Xr$matmul(br) + torch_randn(n_r, 1)
Xrv <- torch_randn(1000, p_r); Yrv <- Xrv$matmul(br) + torch_randn(1000, 1)
fit_reg <- function(kind, epochs = 400, seed = 93) {
torch_manual_seed(seed)
net <- if (kind == "dropout")
nn_sequential(nn_linear(p_r, 512), nn_relu(), nn_dropout(0.5),
nn_linear(512, 512), nn_relu(), nn_dropout(0.5), nn_linear(512, 1))
else
nn_sequential(nn_linear(p_r, 512), nn_relu(),
nn_linear(512, 512), nn_relu(), nn_linear(512, 1))
wd <- if (kind == "weight decay") 0.05 else 0
opt <- optim_adamw(net$parameters, lr = 1e-3, weight_decay = wd)
tr <- va <- numeric(epochs)
for (e in seq_len(epochs)) {
net$train(); opt$zero_grad()
l <- nnf_mse_loss(net(Xr), Yr); l$backward(); opt$step()
net$eval()
with_no_grad({
tr[e] <- as.numeric(nnf_mse_loss(net(Xr), Yr))
va[e] <- as.numeric(nnf_mse_loss(net(Xrv), Yrv))
})
}
data.frame(epoch = 1:epochs, train = tr, valid = va, method = kind)
}
regs <- bind_rows(lapply(c("none", "weight decay", "dropout"), fit_reg))
regs |> pivot_longer(c(train, valid), names_to = "split", values_to = "mse") |>
ggplot(aes(epoch, mse, color = split)) +
geom_line(linewidth = 0.8) +
facet_wrap(~ method) +
scale_y_log10() +
scale_color_manual(values = c(train = "#7FB069", valid = "#D8433B")) +
labs(title = "Regularization on an overparameterized network (786k parameters, 200 observations)",
subtitle = "Unregularized training error goes to zero while validation error rises -- the gap is what regularization closes",
x = "Epoch", y = "MSE (log scale)", color = NULL) +
theme_dspa(10)regs |> summarise(best_valid = min(valid), final_train = last(train), .by = method) |>
mutate(across(where(is.numeric), \(z) signif(z, 4)))# --- Interactive equivalent ------------------------------------------------
d <- filter(regs, method == "none")
plot_ly(d, x = ~epoch, y = ~train, type = "scatter", mode = "lines",
name = "Training") |>
add_lines(y = ~valid, name = "Validation") |>
layout(title = "Unregularized training on a small sample",
xaxis = list(title = "Epoch"),
yaxis = list(title = "MSE", type = "log"))Early stopping is regularization, and it must use a genuine validation split. Choosing the stopping epoch by watching the test set turns the test estimate into a selection-optimized one, the error of Chapter 9, §9.16, applied to a training curve. Split three ways: train, validation (for stopping and hyperparameters), test (touched once).
Three words with precise meanings that are routinely confused.
| Term | Definition | Governs |
|---|---|---|
| Sample | One element of the dataset — a patient, image, or record | The unit of the loss |
| Batch | A subset of \(b\) samples processed together to produce one parameter update | Gradient variance, memory, hardware utilization |
| Epoch | One complete pass over the training set — \(\lceil N/b\rceil\) updates | The unit in which training length is reported |
| Step / iteration | One parameter update, i.e. one batch | What a learning-rate schedule is indexed by |
Common misconception: “larger batches give better fits and cost more compute.” Both halves are wrong in the way that matters.
A larger batch gives a lower-variance gradient estimate, the variance falls as \(\sigma^2/b\) (Chapter 13, §13.9) — but a lower-variance gradient is not a better fit. Large-batch training is well documented to generalize worse at a fixed epoch budget, because the gradient noise that small batches inject acts as an implicit regularizer and steers the trajectory toward flatter regions (Keskar et al., 2017). Compensating requires scaling the learning rate with the batch size (§14.12), the diagonal valley in that section’s surface.
And the cost is memory, not total compute. Per epoch, a larger batch does the same number of FLOPs in fewer, larger matrix multiplications, which is usually faster because it utilizes the hardware better. What grows linearly with \(b\) is the activation memory (§14.3), which is why “reduce the batch size” is the standard response to an out-of-memory error.
# Gradient variance falls as 1/b; per-epoch wall time usually falls too
grad_variance <- function(b, reps = 40, seed = 151) {
torch_manual_seed(seed); set.seed(seed)
net <- nn_sequential(nn_linear(20, 32), nn_relu(), nn_linear(32, 1))
X <- torch_randn(4096, 20); Y <- torch_randn(4096, 1)
gs <- vapply(seq_len(reps), function(r) {
idx <- sample(4096, b)
net$zero_grad()
nnf_mse_loss(net(X[idx, ]), Y[idx, ])$backward()
as.numeric(torch_norm(net[[1]]$weight$grad))
}, numeric(1))
t_epoch <- system.time({
for (s in seq(1, 4096 - b + 1, by = b)) {
net$zero_grad()
nnf_mse_loss(net(X[s:(s+b-1), ]), Y[s:(s+b-1), ])$backward()
}})[["elapsed"]]
c(batch = b, sd_of_gradient_norm = sd(gs), seconds_per_epoch = t_epoch,
activation_MB = 4 * b * 33 / 1e6)
}
as.data.frame(do.call(rbind, lapply(c(8, 32, 128, 512), grad_variance))) |>
mutate(across(everything(), \(z) signif(z, 4)))Gradient variability falls with \(b\) as predicted; wall time per epoch falls too, because larger matrix multiplications use the hardware better; and activation memory rises linearly. Those are three different quantities moving in three different directions, and conflating them is what produces the misconception.
Every deep-learning training loop has the same six steps. Writing it once by hand makes the framework versions legible.
train_manual <- function(net, X, Y, Xv, Yv, epochs = 200, lr = 1e-3,
batch = 64, seed = 101) {
torch_manual_seed(seed); set.seed(seed)
opt <- optim_adamw(net$parameters, lr = lr, weight_decay = 1e-4)
n <- X$size(1)
hist <- data.frame(epoch = integer(), train = numeric(), valid = numeric())
for (e in seq_len(epochs)) {
net$train() # (0) training mode
perm <- sample(n); tot <- 0
for (s in seq(1, n - batch + 1, by = batch)) {
idx <- perm[s:(s + batch - 1)]
opt$zero_grad() # (1) clear accumulated grads
pred <- net(X[idx, ]) # (2) forward
loss <- nnf_mse_loss(pred, Y[idx, ]) # (3) loss
loss$backward() # (4) backward
opt$step() # (5) update
tot <- tot + as.numeric(loss) * length(idx)
}
net$eval() # (6) evaluation mode
v <- with_no_grad(as.numeric(nnf_mse_loss(net(Xv), Yv)))
hist <- rbind(hist, data.frame(epoch = e, train = tot / n, valid = v))
}
list(net = net, history = hist)
}The numbered comments are the whole algorithm. Step (1) is the one most often omitted, §14.2 explains why its absence looks like a learning-rate problem. Steps (0) and (6) are the dropout and BatchNorm mode switches of §14.13.
# --- The same loop with luz, which supplies callbacks, metrics, and devices --
library(luz)
fitted <- nn_sequential(nn_linear(p, 64), nn_relu(),
nn_dropout(0.2), nn_linear(64, 1)) |>
setup(loss = nnf_mse_loss, optimizer = optim_adamw,
metrics = list(luz_metric_rmse())) |>
set_opt_hparams(lr = 1e-3, weight_decay = 1e-4) |>
fit(train_dl, epochs = 200, valid_data = valid_dl,
callbacks = list(
luz_callback_early_stopping(monitor = "valid_loss", patience = 20),
luz_callback_lr_scheduler(lr_one_cycle, max_lr = 1e-2,
epochs = 200, steps_per_epoch = length(train_dl))),
verbose = FALSE) # verbose = FALSE keeps output smallThe point of this section is not that a neural network can be fitted to clinical data. It is whether it should be.
Common misconception: “deep learning is the strongest method available, so use it when accuracy matters.” On tabular data of moderate size, gradient-boosted trees usually match or beat neural networks, train in seconds rather than minutes, need almost no tuning, and handle mixed types and missingness natively (Grinsztajn et al., 2022).
Deep learning’s decisive advantages are in domains with structure a network can exploit, spatial locality in images (Part 2), sequential dependence in text and signals (Part 3), or where the sample is large enough for representation learning to pay for itself. A tabular clinical dataset with a few thousand rows is usually neither.
The only way to know is to run the baselines, on the same split, with the same metric. A neural network reported without a regularized logistic regression and a boosted-tree comparison is an uninterpretable number.
qol <- dspa_read("https://umich.instructure.com/files/481332/download?download_frd=1",
"Case06_QoL_Symptom_ChronicIllness.csv")
if (is.null(qol)) {
set.seed(111); n_s <- 2000; p_s <- 24
Xs <- matrix(rnorm(n_s * p_s), n_s, p_s)
eta <- Xs[,1] * 0.9 - Xs[,2] * 0.7 + 0.5 * Xs[,3] * Xs[,4] + rnorm(n_s, sd = 0.8)
qol <- as.data.frame(round(Xs * 2 + 5))
names(qol) <- paste0("q", seq_len(p_s))
qol$CHRONICDISEASESCORE <- as.numeric(eta > median(eta)) + rnorm(n_s, 1.5, 0.3)
qol$CHARLSONSCORE <- rpois(n_s, 2)
message("Note: QoL file unavailable; using a synthetic substitute.")
}
qol <- qol |> filter(CHRONICDISEASESCORE != -9, CHARLSONSCORE != -9)
cut_cds <- median(qol$CHRONICDISEASESCORE)
qol$cd <- factor(qol$CHRONICDISEASESCORE > cut_cds,
levels = c(FALSE, TRUE), labels = c("minor", "severe"))
# The feature set is defined ONCE, by name. CHRONICDISEASESCORE determines the
# outcome exactly and must be excluded (Chapter 11, Section 11.3).
DROP <- c("ID", "CHRONICDISEASESCORE", "INTERVIEWDATE", "cd")
FEATURES <- setdiff(names(qol), DROP)
FEATURES <- FEATURES[vapply(qol[FEATURES], \(v) is.numeric(v) && var(v, na.rm = TRUE) > 0,
logical(1))]
dat <- qol[complete.cases(qol[, c(FEATURES, "cd")]), c(FEATURES, "cd")]
c(observations = nrow(dat), features = length(FEATURES),
positive_rate = round(mean(dat$cd == "severe"), 4))#> observations features positive_rate
#> 2190.0000 38.0000 0.4991
library(rsample)
set.seed(1234)
# THREE-way split: train fits, validation selects, test is touched once
sp1 <- initial_split(dat, prop = 0.7, strata = cd)
train_raw <- training(sp1)
sp2 <- initial_split(testing(sp1), prop = 0.5, strata = cd)
valid_raw <- training(sp2); test_raw <- testing(sp2)
# Standardization uses TRAINING statistics only (Chapter 5, Section 5.4.1)
mu <- vapply(train_raw[FEATURES], mean, numeric(1))
sdv <- vapply(train_raw[FEATURES], sd, numeric(1)); sdv[sdv == 0] <- 1
scale_with <- function(d) as.matrix(sweep(sweep(as.matrix(d[FEATURES]), 2, mu), 2, sdv, "/"))
Xtr <- scale_with(train_raw); ytr <- as.integer(train_raw$cd == "severe")
Xva <- scale_with(valid_raw); yva <- as.integer(valid_raw$cd == "severe")
Xte <- scale_with(test_raw); yte <- as.integer(test_raw$cd == "severe")
c(train = nrow(Xtr), validation = nrow(Xva), test = nrow(Xte),
no_information_rate = round(max(mean(yte), 1 - mean(yte)), 4))#> train validation test no_information_rate
#> 1532.0000 329.0000 329.0000 0.5015
library(glmnet); library(ranger)
set.seed(121)
cv_lasso <- cv.glmnet(Xtr, ytr, family = "binomial", alpha = 1, nfolds = 10)
p_lasso <- as.numeric(predict(cv_lasso, newx = Xte, s = "lambda.1se", type = "response"))
rf <- ranger(x = Xtr, y = factor(ytr), probability = TRUE, num.trees = 500,
seed = 121)
p_rf <- predict(rf, Xte)$predictions[, "1"]
gbm_ok <- requireNamespace("xgboost", quietly = TRUE)
p_gbm <- if (gbm_ok) {
set.seed(121)
# Create explicit DMatrix objects for training and validation
dtrain <- xgboost::xgb.DMatrix(data = Xtr, label = ytr)
dval <- xgboost::xgb.DMatrix(data = Xva, label = yva)
bst <- xgboost::xgb.train(
params = list(
objective = "binary:logistic",
max_depth = 4,
eta = 0.05,
subsample = 0.8,
colsample_bytree = 0.8
),
data = dtrain,
nrounds = 300,
watchlist = list(val = dval), # <-- Fixed from 'evals' to 'watchlist'
early_stopping_rounds = 30,
verbose = 0
)
predict(bst, Xte)
} else {
rep(NA_real_, length(yte))
}torch_manual_seed(131); set.seed(131)
# Xtr is already numeric, so it defaults to Float.
# ytr and yva are integers, so we must force them to Float32 for BCE loss.
Xtr_t <- torch_tensor(Xtr)
ytr_t <- torch_tensor(matrix(ytr, ncol = 1), dtype = torch_float32())
Xva_t <- torch_tensor(Xva)
yva_t <- torch_tensor(matrix(yva, ncol = 1), dtype = torch_float32())
Xte_t <- torch_tensor(Xte)
mlp <- nn_sequential(
nn_linear(ncol(Xtr), 128), nn_batch_norm1d(128), nn_relu(), nn_dropout(0.3),
nn_linear(128, 64), nn_batch_norm1d(64), nn_relu(), nn_dropout(0.3),
nn_linear(64, 1))
opt <- optim_adamw(mlp$parameters, lr = 3e-3, weight_decay = 1e-3)
epochs <- 300; bs <- 64; n_tr <- nrow(Xtr)
hist <- data.frame(epoch = integer(), train = numeric(), valid = numeric())
best_val <- Inf; best_state <- NULL; patience <- 40; since <- 0
for (e in seq_len(epochs)) {
mlp$train(); perm <- sample(n_tr); tot <- 0
for (s in seq(1, n_tr - bs + 1, by = bs)) {
idx <- perm[s:(s + bs - 1)]
opt$zero_grad()
l <- nnf_binary_cross_entropy_with_logits(mlp(Xtr_t[idx, ]), ytr_t[idx, ])
l$backward(); opt$step()
tot <- tot + as.numeric(l) * length(idx)
}
mlp$eval()
v <- with_no_grad(as.numeric(
nnf_binary_cross_entropy_with_logits(mlp(Xva_t), yva_t)))
hist <- rbind(hist, data.frame(epoch = e, train = tot / n_tr, valid = v))
# Early stopping on the VALIDATION set; the test set is never consulted
if (v < best_val - 1e-5) {
best_val <- v; since <- 0
best_state <- lapply(mlp$state_dict(), \(t) t$clone())
} else if ((since <- since + 1) >= patience) break
}
mlp$load_state_dict(best_state); mlp$eval()
c(epochs_run = nrow(hist), best_epoch = which.min(hist$valid),
best_validation_loss = round(best_val, 4))#> epochs_run best_epoch best_validation_loss
#> 47.000 7.000 0.659
ggplot(pivot_longer(hist, c(train, valid), names_to = "split", values_to = "loss"),
aes(epoch, loss, color = split)) +
geom_vline(xintercept = which.min(hist$valid), linetype = "dashed",
color = "grey35") +
geom_line(linewidth = 0.8) +
scale_color_manual(values = c(train = "#7FB069", valid = "#D8433B")) +
labs(title = "Training curve with early stopping",
subtitle = "Dashed: the selected epoch, chosen on validation loss. The test set plays no part in this decision",
x = "Epoch", y = "Binary cross-entropy", color = NULL) +
theme_dspa()library(pROC)
p_mlp <- as.numeric(with_no_grad(torch_sigmoid(mlp(Xte_t))))
score <- function(p, label) {
if (all(is.na(p))) return(NULL)
auc <- as.numeric(pROC::auc(pROC::roc(yte, p, quiet = TRUE)))
brier <- mean((p - yte)^2)
pc <- pmin(pmax(p, 1e-6), 1 - 1e-6)
slope <- unname(coef(glm(yte ~ qlogis(pc), family = binomial()))[2])
data.frame(model = label, AUC = round(auc, 4), Brier = round(brier, 4),
calibration_slope = round(slope, 3),
accuracy = round(mean((p > 0.5) == yte), 4))
}
results <- bind_rows(
data.frame(model = "Majority class", AUC = 0.5,
Brier = round(mean((mean(ytr) - yte)^2), 4),
calibration_slope = NA,
accuracy = round(max(mean(yte), 1 - mean(yte)), 4)),
score(p_lasso, "LASSO logistic"),
score(p_rf, "Random forest"),
if (gbm_ok) score(p_gbm, "Gradient boosting") else NULL,
score(p_mlp, "MLP (this section)"))
results |> arrange(desc(AUC))Read the AUC column against the Brier score and the calibration slope together, as Chapter 9, §9.8 requires. AUC measures ranking only; a model can lead on AUC and produce probabilities that cannot be believed. A calibration slope below 1 means the predictions are too extreme, which is the characteristic failure of overparameterized networks and the subject of Part 5, §14.71.
# The cost side of the comparison, which accuracy tables omit
n_par_mlp <- sum(vapply(mlp$parameters, \(p) prod(dim(p)), numeric(1)))
data.frame(
model = c("LASSO logistic", "Random forest", "MLP"),
parameters_or_nodes = c(ncol(Xtr) + 1, "500 trees", n_par_mlp),
tuning_burden = c("1 (lambda, by CV)", "2-3 (mtry, depth)",
"8+ (width, depth, lr, batch, dropout, decay, schedule, epochs)"),
interpretable = c("yes: signed coefficients", "partially: importances",
"no, without post-hoc methods"))Training failures have distinctive signatures. Reading them saves hours.
| Symptom | Likely cause | Check |
|---|---|---|
Loss is NaN after a few steps |
Learning rate too large; exploding gradients; \(\log(0)\) | Reduce \(\eta\); clip gradients;
use *_with_logits losses |
| Loss flat from step 1 | Dead ReLUs, vanishing gradient, or \(\eta\) far too small | Gradient norms per layer (§14.8) |
| Loss decreases then diverges | zero_grad() missing, or \(\eta>2/L\) |
Inspect the loop; halve \(\eta\) |
| Training loss falls, validation rises | Overfitting | Regularize; early stop (§14.13) |
| Validation below training loss | Dropout still on at evaluation | model$eval() |
| Cannot overfit 10 examples | A bug, not a modelling problem | Overfit-a-batch test |
| Results change across identical runs | Unseeded, or GPU nondeterminism | §14.19 |
The single most useful diagnostic: try to overfit a tiny batch. Take ten examples, remove all regularization, and train until the loss reaches essentially zero. A correctly wired network must be able to memorize ten points. If it cannot, the problem is a bug, a shape mismatch, a detached graph, a missing
zero_grad(), a loss applied to the wrong tensor, and no amount of hyperparameter tuning will fix it. This test takes seconds and separates implementation errors from modelling questions.
torch_manual_seed(141)
Xtiny <- Xtr_t[1:10, ]; ytiny <- ytr_t[1:10, ]
tiny <- nn_sequential(nn_linear(ncol(Xtr), 64), nn_relu(), nn_linear(64, 1))
opt_t <- optim_adam(tiny$parameters, lr = 0.05)
for (s in 1:500) {
opt_t$zero_grad()
l <- nnf_binary_cross_entropy_with_logits(tiny(Xtiny), ytiny)
l$backward(); opt_t$step()
}
c(final_loss_on_10_examples = signif(as.numeric(l), 3),
accuracy = mean((as.numeric(torch_sigmoid(tiny(Xtiny))) > 0.5) == as.numeric(ytiny)),
verdict = "wiring is correct")#> final_loss_on_10_examples accuracy verdict
#> "0" "1" "wiring is correct"
# Per-layer gradient norms: the diagnostic for a flat loss
torch_manual_seed(143)
probe <- nn_sequential(
nn_linear(ncol(Xtr), 128), nn_sigmoid(), # sigmoid chosen to show the problem
nn_linear(128, 128), nn_sigmoid(),
nn_linear(128, 128), nn_sigmoid(),
nn_linear(128, 128), nn_sigmoid(),
nn_linear(128, 1))
l <- nnf_binary_cross_entropy_with_logits(probe(Xtr_t), ytr_t)
l$backward()
gn <- data.frame(
layer = seq_along(Filter(\(m) inherits(m, "nn_linear"), probe$modules)),
grad_norm = vapply(Filter(\(m) inherits(m, "nn_linear"), probe$modules),
\(m) as.numeric(torch_norm(m$weight$grad)), numeric(1)))
gn$grad_norm <- gn$grad_norm[!is.na(gn$grad_norm)][seq_len(nrow(gn))]
ggplot(gn, aes(layer, pmax(grad_norm, 1e-16))) +
geom_col(fill = "steelblue", width = 0.6) +
scale_y_log10() +
scale_x_continuous(breaks = gn$layer) +
labs(title = "Gradient norm by layer, sigmoid activations",
subtitle = "The signal decays by orders of magnitude toward the input -- early layers receive almost nothing",
x = "Linear layer (1 = closest to input)", y = "Gradient norm (log scale)") +
theme_dspa()Neural network objectives are non-convex (Chapter 13, §13.14), and it is worth seeing what that means here rather than in a two-dimensional toy.
A trained network’s landscape can be visualized on a random two-dimensional slice through parameter space. Following Li et al. (2018), the two directions are filter-normalized, each random direction is rescaled layer by layer to match the corresponding weight’s norm, because without that normalization the apparent flatness is an artifact of scale invariance rather than a property of the loss.
filter_normalized_dir <- function(params, seed) {
torch_manual_seed(seed)
lapply(params, function(p) {
d <- torch_randn_like(p)
d * (torch_norm(p) / (torch_norm(d) + 1e-10))
})
}
landscape <- function(net, X, Y, span = 1.0, n_grid = 21) {
base <- lapply(net$parameters, \(p) p$clone()$detach())
d1 <- filter_normalized_dir(base, 201)
d2 <- filter_normalized_dir(base, 202)
alphas <- seq(-span, span, length.out = n_grid)
Z <- matrix(NA_real_, n_grid, n_grid)
ps <- net$parameters
for (i in seq_along(alphas)) for (j in seq_along(alphas)) {
with_no_grad({
for (k in seq_along(ps))
ps[[k]]$copy_(base[[k]] + alphas[i] * d1[[k]] + alphas[j] * d2[[k]])
Z[i, j] <- as.numeric(nnf_binary_cross_entropy_with_logits(net(X), Y))
})
}
with_no_grad(for (k in seq_along(ps)) ps[[k]]$copy_(base[[k]])) # restore
list(alphas = alphas, Z = Z)
}
ls_res <- landscape(mlp, Xva_t, yva_t, span = 0.9, n_grid = 21)
c(loss_at_minimum = signif(ls_res$Z[11, 11], 4),
loss_at_corner = signif(ls_res$Z[1, 1], 4),
ratio = signif(ls_res$Z[1, 1] / ls_res$Z[11, 11], 3))#> loss_at_minimum loss_at_corner ratio
#> 0.659 2.015 3.060
plot_ly(x = ls_res$alphas, y = ls_res$alphas, z = ls_res$Z, type = "surface",
colorscale = "Viridis",
colorbar = list(title = "Validation\nloss")) |>
add_trace(x = 0, y = 0, z = ls_res$Z[11, 11], type = "scatter3d",
mode = "markers", name = "Trained solution",
marker = list(size = 6, color = "red")) |>
layout(title = "Filter-normalized loss surface around the trained solution",
scene = list(xaxis = list(title = "Direction 1"),
yaxis = list(title = "Direction 2"),
zaxis = list(title = "Validation loss")))The red marker is the solution early stopping selected. Rotate to see the basin: a wide, relatively flat region rather than a sharp needle. The relationship between basin flatness and generalization is contested, the measure is not reparameterization-invariant, which is exactly why the filter normalization is required, but the qualitative picture is that trained networks sit in broad valleys, not isolated pits.
\(B\) = batch size, \(d_\ell\) = width of layer \(\ell\), \(L\) = depth, \(P\) = parameter count, \(E\) = epochs, \(N\) = training-set size.
| Quantity | Cost | Note |
|---|---|---|
| Forward pass, one linear layer | \(O(Bd_{\text{in}}d_{\text{out}})\) | \(\approx 2Bd_{\text{in}}d_{\text{out}}\) FLOPs |
| Backward pass | \(\approx 2\times\) forward | Gradients w.r.t. both inputs and weights |
| Full training | \(O\!\big(ENL\bar d^{\,2}\big)\) | Three passes’ worth per step |
| Parameter memory | \(4P\) bytes (float32) | Stored once |
| Gradient memory | \(4P\) bytes | Stored once |
| Adam state | \(8P\) bytes | Two moment estimates — triples parameter memory |
| Activation memory | \(4B\sum_\ell d_\ell\) bytes | Scales with batch size; usually binding |
| Gradient checkpointing | \(O(\sqrt L)\) activations | Recompute in the backward pass: time for space |
| Reverse-mode AD gradient | \(O(1)\) forward passes | Independent of \(P\) |
| Finite-difference gradient | \(O(P)\) forward passes | Infeasible beyond toy sizes |
Four consequences.
Reverse-mode AD is what makes any of this possible. A gradient for \(10^8\) parameters costs a constant multiple of one forward pass; by finite differences it would cost \(2\times10^8\) forward passes. That single asymptotic fact, from Chapter 13, §13.25, is the foundation of the field.
Adam’s memory cost is not incidental. Weights, gradients, and two moment estimates total \(16P\) bytes at float32, a \(10^9\)-parameter model needs 16 GB before a single activation is stored. This is why optimizer sharding and 8-bit optimizer states exist.
Activation memory scales with the batch and parameters do not. Reducing the batch size is the standard response to an out-of-memory error precisely because it is the only term that shrinks.
Depth costs linearly in compute and linearly in activation memory, but its representational return is exponential (§14.7). That asymmetry is why networks got deeper rather than wider.
Common misconception: “set a seed and the run is reproducible.” On CPU with a fixed library version, largely true. On GPU it is not, and the reasons are structural rather than fixable by better practice.
cuDNN selects algorithms by benchmarked speed, and the fastest kernel can differ between runs on the same hardware. Many reductions use atomic accumulation, whose order is nondeterministic, and floating-point addition is not associative, so \((a+b)+c\ne a+(b+c)\) at the bit level. Multi-threaded CPU reductions have the same property.
What can be guaranteed: seed
torch_manual_seed(),set.seed(), and the data-loader workers; pin library versions; and where determinism matters more than speed, request deterministic algorithms explicitly and accept the slowdown. What should be reported: the seed, the library versions, the hardware, and, most usefully, results across several seeds, since a single-seed result on a stochastic procedure is one draw from a distribution.
run_once <- function(seed) {
torch_manual_seed(seed); set.seed(seed)
net <- nn_sequential(nn_linear(ncol(Xtr), 32), nn_relu(), nn_linear(32, 1))
opt <- optim_adam(net$parameters, lr = 0.01)
for (s in 1:100) {
opt$zero_grad()
l <- nnf_binary_cross_entropy_with_logits(net(Xtr_t), ytr_t)
l$backward(); opt$step()
}
net$eval()
as.numeric(pROC::auc(pROC::roc(
yva, as.numeric(with_no_grad(torch_sigmoid(net(Xva_t)))), quiet = TRUE)))
}
same_seed <- c(run_once(7), run_once(7))
diff_seeds <- vapply(1:8, run_once, numeric(1))
c(identical_across_same_seed = isTRUE(all.equal(same_seed[1], same_seed[2])),
mean_auc_across_8_seeds = round(mean(diff_seeds), 4),
sd_across_seeds = round(sd(diff_seeds), 4),
range = paste(round(range(diff_seeds), 4), collapse = " to "))#> identical_across_same_seed mean_auc_across_8_seeds
#> "TRUE" "0.5783"
#> sd_across_seeds range
#> "0.0178" "0.5626 to 0.612"
The spread across seeds is the honest uncertainty in a single reported number. Quoting one run’s AUC without it overstates precision.
| # | Pitfall | Consequence | Fix |
|---|---|---|---|
| 1 | Omitting zero_grad() |
Gradients accumulate; loss diverges after a few steps | Clear before every backward pass |
| 2 | Evaluating without model$eval() |
Dropout and BatchNorm still in training mode | Switch modes; validation below training loss is the tell |
| 3 | Evaluating without with_no_grad() |
Builds a graph; wastes memory and time | Wrap all inference |
| 4 | Reading universal approximation as learnability | Existence says nothing about optimization or generalization | Width may be exponential in \(d\) |
| 5 | Stacking linear layers without activations | The whole network collapses to one affine map | Nonlinearity between every pair |
| 6 | Default or ad-hoc initialization at depth | Activations vanish or explode geometrically | He for ReLU, Xavier for tanh |
| 7 | Treating ReLU as gradient-safe | Dying units, permanently | Monitor dead fractions; leaky/GELU/SiLU |
| 8 | Explaining BatchNorm by internal covariate shift | The mechanism is loss smoothing | Also: BatchNorm couples examples in a batch |
| 9 | BatchNorm with tiny batches | Batch statistics too noisy to normalize with | LayerNorm, GroupNorm, or larger batches |
| 10 | weight_decay in Adam read as \(L_2\) |
Per-parameter decay varies with gradient history | AdamW decouples it |
| 11 | Tuning the learning rate independently of batch size | The good region is a diagonal valley | Scale \(\eta\) with batch size |
| 12 | No warmup with an adaptive optimizer | Early moment estimates give huge, erratic steps | Linear warmup over a few hundred steps |
| 13 | Early stopping on the test set | Turns the test estimate into a selection-optimized one | Three-way split |
| 14 | Reporting a network without baselines | An uninterpretable number | LASSO, random forest, boosting on the same split |
| 15 | Assuming deep learning wins on tabular data | Boosted trees usually match or beat it | Run them |
| 16 | Reading AUC as sufficient | Ranking says nothing about probabilities | Brier and calibration slope too |
| 17 | Hyperparameter tuning without a validation split | Optimism proportional to the search size | Nested protocol |
| 18 | Debugging a bug by tuning | A mis-wired network cannot be tuned into working | Overfit ten examples first |
| 19 | Ignoring per-layer gradient norms | Vanishing gradients look like slow learning | Log norms by layer |
| 20 | float64 for training | Doubles memory and traffic; buys nothing | float32, or mixed precision |
| 21 | Assuming a seed gives GPU determinism | cuDNN selection and atomics are nondeterministic | Report seeds, versions, and multi-seed spread |
| 22 | Reporting one run | One draw from a distribution | Several seeds; report the spread |
| 23 | Printing model objects or training logs into the document | Output balloons past 100 MB | verbose = 0; cache artifacts to disk |
| 24 | Reading the biological analogy as mechanism | Backprop has no biological counterpart | A network is a composition of affine maps and nonlinearities |
| 25 | Composing sigmoid or softmax with a separate log |
NaN on the first backward pass |
Fused *_with_logits losses; they take raw
logits |
| 26 | Passing already-sigmoided values to a with_logits
loss |
Nonlinearity applied twice; silently wrong | Pass the raw output layer |
| 27 | Confusing \(W^{(\ell)}\)’s columns with the next layer | Every shape argument off by one layer | Rows = current units, columns = previous |
| 28 | Calling the bias a residual | It is a learned intercept, not a fit property | \(b\) is estimated; \(y-\hat y\) is observed |
| 29 | “Larger batches fit better and cost more compute” | Three quantities conflated | Variance falls as \(1/b\); memory rises; per-epoch time usually falls |
| 30 | Retired packages (mxnet, darch,
deepnet, kerasR) |
Unavailable or unmaintained | torch + luz |
Problems continue in Part 2; those below use only Part 1 material.
# A two-layer network's gradient, by autograd and by hand
torch_manual_seed(301)
W1 <- torch_randn(4, 3, requires_grad = TRUE)
W2 <- torch_randn(1, 4, requires_grad = TRUE)
xin <- torch_randn(1, 3); ytgt <- torch_tensor(matrix(1.0))
h <- torch_tanh(xin$matmul(W1$t()))
out <- h$matmul(W2$t())
loss <- ((out - ytgt)^2)$sum()
loss$backward()
# By hand: dL/dW2 = 2(out-y) h ; dL/dW1 = [2(out-y) W2 * (1-h^2)]^T x
with_no_grad({
delta <- 2 * (out - ytgt)
gW2_manual <- delta$t()$matmul(h)
gh <- delta$matmul(W2) * (1 - h^2)
gW1_manual <- gh$t()$matmul(xin)
})
c(W2_max_abs_diff = signif(as.numeric(torch_max(torch_abs(W2$grad - gW2_manual))), 3),
W1_max_abs_diff = signif(as.numeric(torch_max(torch_abs(W1$grad - gW1_manual))), 3))#> W2_max_abs_diff W1_max_abs_diff
#> 0 0
They agree to machine precision. Autograd is not an
approximation, it applies the chain rule exactly, which is what
distinguishes it from the finite differences of Chapter 13,
§13.25.
depth_test <- function(depth, act, steps = 200, width = 64, seed = 311) {
torch_manual_seed(seed); set.seed(seed)
X <- torch_randn(256, width); Y <- torch_randn(256, 1)
layers <- list()
for (l in seq_len(depth)) layers <- c(layers, list(nn_linear(width, width), act()))
layers <- c(layers, list(nn_linear(width, 1)))
net <- do.call(nn_sequential, layers)
opt <- optim_sgd(net$parameters, lr = 0.05)
l0 <- as.numeric(with_no_grad(nnf_mse_loss(net(X), Y)))
for (s in seq_len(steps)) {
opt$zero_grad(); l <- nnf_mse_loss(net(X), Y); l$backward(); opt$step()
}
1 - as.numeric(with_no_grad(nnf_mse_loss(net(X), Y))) / l0
}
dps <- c(1, 2, 4, 8, 16, 24)
p2 <- bind_rows(lapply(c(Sigmoid = "nn_sigmoid", ReLU = "nn_relu"), \(a)
data.frame(depth = dps, act = a,
reduction = vapply(dps, \(d) depth_test(d, get(a)), numeric(1))))) |>
mutate(act = ifelse(act == "nn_sigmoid", "Sigmoid", "ReLU"))
p2 |> pivot_wider(names_from = act, values_from = reduction) |>
mutate(across(-depth, \(z) round(z, 4)))ggplot(p2, aes(depth, reduction, color = act)) +
geom_hline(yintercept = 0, color = "grey70") +
geom_line(linewidth = 1) + geom_point(size = 2.4) +
scale_x_log10(breaks = dps) +
scale_color_manual(values = c(Sigmoid = "#D8433B", ReLU = "#3B7DD8")) +
labs(title = "Fractional loss reduction in 200 steps, by depth",
subtitle = "Identical budget and learning rate. Sigmoid stops making progress well before ReLU does",
x = "Hidden layers (log scale)", y = "Fraction of initial loss removed",
color = NULL) +
theme_dspa()# ReLU halves the variance of a symmetric input, so He doubles the weight variance
torch_manual_seed(321)
z <- torch_randn(200000)
c(var_before_relu = round(as.numeric(torch_var(z)), 4),
var_after_relu = round(as.numeric(torch_var(torch_relu(z))), 4),
ratio = round(as.numeric(torch_var(torch_relu(z))) / as.numeric(torch_var(z)), 4),
theoretical_ratio = round((1 - 1/pi) / 2 + 0, 4))#> var_before_relu var_after_relu ratio theoretical_ratio
#> 0.9985 0.3405 0.3410 0.3408
# Empirical check of the layer-to-layer variance ratio at each gain
one_layer_ratio <- function(gain, width = 512, seed = 323) {
torch_manual_seed(seed)
h <- torch_randn(4096, width)
W <- torch_randn(width, width) * sqrt(gain / width)
as.numeric(torch_var(torch_relu(h$matmul(W$t())))) / as.numeric(torch_var(h))
}
data.frame(gain = c(1, 2, 4),
variance_ratio = round(vapply(c(1, 2, 4), one_layer_ratio, numeric(1)), 4),
preserves_variance = abs(vapply(c(1,2,4), one_layer_ratio, numeric(1)) - 1) < 0.1)best_lr_for_batch <- function(bs) {
lrs_try <- 10^seq(-3, -0.3, length.out = 12)
v <- vapply(lrs_try, \(l) train_lb(l, bs), numeric(1))
lrs_try[which.min(v)]
}
bs_try <- c(8, 16, 32, 64, 128, 256)
p4 <- data.frame(batch = bs_try,
best_lr = vapply(bs_try, best_lr_for_batch, numeric(1)))
p4$ratio_to_smallest <- round(p4$best_lr / p4$best_lr[1], 3)
p4$linear_rule <- round(p4$batch / p4$batch[1], 3)
p4ggplot(p4, aes(batch, best_lr)) +
geom_line(linewidth = 1, color = "steelblue") + geom_point(size = 2.6) +
geom_line(aes(y = best_lr[1] * batch / batch[1]), linetype = "dashed",
color = "firebrick") +
scale_x_log10(breaks = bs_try) + scale_y_log10() +
annotate("text", x = 100, y = max(p4$best_lr) * 1.4, size = 3.2,
color = "firebrick", label = "linear scaling rule") +
labs(title = "Best learning rate against batch size",
subtitle = "Dashed: the linear scaling rule. On log-log axes a slope of 1 confirms it",
x = "Batch size (log scale)", y = "Best learning rate (log scale)") +
theme_dspa()# A deliberately broken network: the second layer is detached from the graph
broken_fit <- function(detach_it) {
torch_manual_seed(331)
L1 <- nn_linear(ncol(Xtr), 32); L2 <- nn_linear(32, 1)
opt <- optim_adam(c(L1$parameters, L2$parameters), lr = 0.05)
Xb <- Xtr_t[1:10, ]; yb <- ytr_t[1:10, ]
for (s in 1:400) {
opt$zero_grad()
h <- torch_relu(L1(Xb))
if (detach_it) h <- h$detach() # the bug: gradient stops here
l <- nnf_binary_cross_entropy_with_logits(L2(h), yb)
l$backward(); opt$step()
}
as.numeric(l)
}
data.frame(
version = c("correct", "first layer detached"),
final_loss_on_10_examples = signif(c(broken_fit(FALSE), broken_fit(TRUE)), 4),
can_memorize_10_points = c(broken_fit(FALSE) < 0.01, broken_fit(TRUE) < 0.01))seeds <- 1:15
aucs <- vapply(seeds, run_once, numeric(1))
c(mean = round(mean(aucs), 4), sd = round(sd(aucs), 4),
min = round(min(aucs), 4), max = round(max(aucs), 4),
range_width = round(diff(range(aucs)), 4),
ci95 = paste(round(mean(aucs) + c(-1.96, 1.96) * sd(aucs) / sqrt(length(aucs)), 4),
collapse = " to "))#> mean sd min max
#> "0.5784" "0.0183" "0.5625" "0.6204"
#> range_width ci95
#> "0.058" "0.5691 to 0.5876"
ggplot(data.frame(auc = aucs), aes(auc)) +
geom_histogram(bins = 10, fill = "steelblue", color = "white") +
geom_vline(xintercept = mean(aucs), color = "firebrick", linewidth = 1) +
labs(title = sprintf("Validation AUC across %d random seeds", length(seeds)),
subtitle = "Identical architecture, data, and hyperparameters. Only the initialization and batch order differ",
x = "AUC", y = "Runs") +
theme_dspa()NaN after ten steps. Name three causes and
how to distinguish them.weight_decay = 0.01. Is that \(L_2\) regularization?NaN appears on the
very first backward pass, suspect \(\log(0)\) from a manually composed
sigmoid-then-cross-entropy: use the fused *_with_logits
form, which is numerically stable. Also check for NaN in
the inputs, which propagates silently.model$eval() before scoring and
model$train() before the next epoch. Two lesser causes are
worth ruling out: a validation set that is easier than the training set
(small or badly stratified), and regularization applied to the training
loss but not the validation loss, which makes the two numbers different
quantities.zero_grad() is being called, that the optimizer actually
received the parameters, and that the learning rate is not absurdly
small. And run the overfit-ten-examples test, if the
network cannot memorize ten points, the problem is a bug, not a
hyperparameter.The computational substrate
zero_grad()
before every backward pass.Representation
Making training work
Practice
| Continue with | Content |
|---|---|
| Part 2: Convolutional Networks and Vision | Convolution as structured sparsity, receptive fields, residual connections and the gradient-flow argument that resolves §14.7’s depth-versus-optimization tension, transfer learning, CIFAR-10, interpretability |
| Part 3: Sequence Models | RNNs and LSTM gating, attention derived from the alignment problem, Transformers, and the \(O(T^2d)\)-versus-\(O(Td^2)\) comparison that closes Chapter 12’s table |
| Part 4: Generative and Representation Learning | Autoencoders and their exact relation to PCA, the ELBO derived, GANs, diffusion, self-supervised objectives |
| Part 5: Generalization, Uncertainty, and Practice | Double descent, why classical capacity bounds fail, calibration of deep networks (Chapter 9), ensembles, Bayesian hyperparameter optimization (Chapter 13), pruning and sparsity (Chapter 11) |
Earlier chapters this part depended on
#> R version 4.3.3 (2024-02-29 ucrt)
#> Platform: x86_64-w64-mingw32/x64 (64-bit)
#> Running under: Windows 11 x64 (build 26200)
#>
#> Matrix products: default
#>
#>
#> locale:
#> [1] LC_COLLATE=English_United States.utf8
#> [2] LC_CTYPE=English_United States.utf8
#> [3] LC_MONETARY=English_United States.utf8
#> [4] LC_NUMERIC=C
#> [5] LC_TIME=English_United States.utf8
#>
#> time zone: America/New_York
#> tzcode source: internal
#>
#> attached base packages:
#> [1] stats graphics grDevices utils datasets methods base
#>
#> other attached packages:
#> [1] pROC_1.18.5 ranger_0.16.0 glmnet_4.1-8 Matrix_1.6-5
#> [5] rsample_1.2.1 luz_0.5.2 torch_0.13.0 plotly_4.12.1
#> [9] patchwork_1.3.0 tidyr_1.3.1 dplyr_1.1.4 ggplot2_4.0.1
#>
#> loaded via a namespace (and not attached):
#> [1] shape_1.4.6.1 gtable_0.3.6 xfun_0.52 bslib_0.9.0
#> [5] htmlwidgets_1.6.4 processx_3.8.6 lattice_0.22-6 callr_3.7.6
#> [9] vctrs_0.6.5 tools_4.3.3 crosstalk_1.2.1 ps_1.9.0
#> [13] generics_0.1.3 parallel_4.3.3 tibble_3.2.1 pkgconfig_2.0.3
#> [17] data.table_1.16.4 RColorBrewer_1.1-3 S7_0.2.1 lifecycle_1.0.5
#> [21] compiler_4.3.3 farver_2.1.2 progress_1.2.3 codetools_0.2-20
#> [25] htmltools_0.5.8.1 sass_0.4.9 yaml_2.3.10 pillar_1.10.1
#> [29] furrr_0.3.1 crayon_1.5.3 jquerylib_0.1.4 cachem_1.1.0
#> [33] iterators_1.0.14 foreach_1.5.2 parallelly_1.37.1 tidyselect_1.2.1
#> [37] digest_0.6.37 future_1.33.2 purrr_1.0.2 listenv_0.9.1
#> [41] splines_4.3.3 labeling_0.4.3 fastmap_1.2.0 grid_4.3.3
#> [45] cli_3.6.3 magrittr_2.0.3 survival_3.7-0 withr_3.0.2
#> [49] prettyunits_1.2.0 scales_1.4.0 xgboost_1.7.11.1 bit64_4.0.5
#> [53] rmarkdown_2.31 httr_1.4.7 globals_0.16.3 bit_4.0.5
#> [57] otel_0.2.0 hms_1.1.3 evaluate_1.0.3 knitr_1.51
#> [61] viridisLite_0.4.2 rlang_1.1.5 Rcpp_1.0.14 zeallot_0.1.0
#> [65] glue_1.8.0 coro_1.0.4 rstudioapi_0.18.0 jsonlite_1.8.9
#> [69] plyr_1.8.9 R6_2.6.1 fs_1.6.5