From nonlinear prediction rules to SGD and validation
CUHK · Department of Statistics and Data Science
Each feature contributes through one learned coefficient.
Training chooses \(w,b\) to minimize the average squared error on the training rows.
Same data, same loss, same training question: minimize an average loss over the training set.
One fixed transformation; features enter additively.
Several learned transformations, with nonlinearities between them.
The objective is unchanged. We only make the prediction rule \(f_\theta\) more flexible.
ReLU, \(A(z)=\max(0,z)\), is the default nonlinearity in this course: cheap, simple, and enough to stop layers collapsing.
\(n\) training examples: features \(x_i\) and target \(y_i\).
A prediction rule with parameters \(\theta\).
How wrong one prediction is; for ratings, squared error.
The average loss over the training set; training minimizes it.
Everything that follows is about minimizing \(J(\theta)\): define \(f_\theta\), choose \(L\), and update \(\theta\).
The network’s weights right now.
How the loss changes when each weight moves.
The learning rate controls how far we move.
This is exact, but one step uses all \(n\) training examples. That is expensive when \(n\) is large.
For \(J(w)=(w-3)^2\), start at \(w=0\) and choose \(\eta=0.2\).
| point | weight | loss |
|---|---|---|
| before | \(0\) | \(9\) |
| after one step | \(1.2\) | \(3.24\) |
| minimum | \(3\) | \(0\) |
The same idea updates all network weights at once; the gradient is then a large vector, not a single slope.
Minimize \(L(\theta_1,\theta_2)=\theta_1^2+\theta_2^2\) with \(\eta=0.2\) from \(\theta^{(0)}=(2,1)\).
| \(t\) | \(\theta_1\) | \(\theta_2\) | \(L\) |
|---|---|---|---|
| 0 | 2.00 | 1.00 | 5.00 |
| 1 | 1.20 | 0.60 | 1.80 |
| 2 | 0.72 | 0.36 | 0.65 |
| 3 | 0.43 | 0.22 | 0.23 |
| 4 | 0.26 | 0.13 | 0.08 |
The gradient is a vector: one partial derivative per parameter. A single learning rate must balance all directions; that is why \(\eta\) is so important.
Write the per-example loss as \(\ell_i(\theta)=L(y_i,f_\theta(x_i))\)
The full gradient is an average over all \(n\) examples.
One update needs all \(n\) rows.
A uniformly sampled batch estimates the full gradient.
Small \(b\): noisy but cheap. Large \(b\): expensive but less noisy.
A mini-batch turns one expensive exact step into many cheap noisy steps. The noise is the price of not touching all \(n\) rows.
A batch \(B\) is a subset of the training rows. Replace the full average by the average over \(B\).
Exact gradient, but one step costs a full pass over the data.
The usual SGD compromise: a noisy but cheap gradient, many updates per epoch.
Very noisy gradient, but the cheapest possible update.
Uses every training row once on average; with batch size \(b\), about \(n/b\) updates.
A batch is the set of rows used for one update; an epoch is one pass over all rows.
Illustrative example: 12 rows, batch size 4, so one epoch has 3 updates.
A batch gives one gradient step. An epoch is one pass over all rows, so it gives about \(n/b\) steps.
| term | meaning |
|---|---|
| loss \(L\) | error for one prediction |
| objective \(J\) | average loss over the training set |
| gradient | derivative of the objective with respect to the parameters |
| batch | the subset of rows used for one update |
| batch size \(b\) | number of rows in that subset |
| iteration / step | one gradient update, using one batch |
| epoch | one pass over the training set; about \(n/b\) updates |
| learning rate \(\eta\) | step size in the parameter update |
| shuffle | reshuffle row order each epoch, changing the batches |
A batch is not a model and not a dataset: it is the set of rows used for one SGD step.
Learn two vectors and combine them with an inner product.
A network can combine the same inputs through several linear and nonlinear steps.
The ratings, the loss, and the validation discipline stay the same. The function class changes.
TensorDataset + DataLoader
nn.Linear, nn.ReLU, nn.Sequential, nn.Module
nn.MSELoss
torch.optim.SGD + training loop
Each question has a code home. Evaluation is the check after the four modules: validation and RMSE/MAE.
Rows contain features and a target; training consumes small batches, not the whole table.
TensorDataset + DataLoader
Convert rows to tensors, pair \(x_i\) with \(y_i\), then yield mini-batches.
The data module’s output is a stream of (xb, yb) batches with a known shape.
from torch.utils.data import DataLoader, TensorDataset
X = torch.tensor(X_train, dtype=torch.float32)
y = torch.tensor(y_train, dtype=torch.float32).unsqueeze(1)
loader = DataLoader(TensorDataset(X, y),
batch_size=64, shuffle=True)Features are float32; targets match the model output shape.
TensorDataset pairs each \(x_i\) with its \(y_i\).
shuffle=True reshuffles every epoch; batch_size is a hyperparameter.
The loader yields (xb, yb) with shapes (64,8) and (64,1); the training loop consumes those batches.
A composition of learned linear maps and fixed nonlinear activations.
nn.Module + layers
Pick bricks, check their shapes, then snap them together with Sequential or a custom Module.
Think of a model as a row of bricks: each brick transforms the tensor and passes it to the next.
Mix the inputs and shift the result.
Let the next layer respond differently in different input regions.
“Deep” means we compose several layers; each layer creates the input for the next.
Values supplied for one example.
Weights and bias adjusted during fitting.
The transformed signal sent forward.
A layer contains many neurons; the layer’s outputs form a vector.
Each edge is a weight, and each node adds a bias before applying \(A\). This toy network has 35 learned numbers: \((4\cdot3+4)+(3\cdot4+3)+(1\cdot3+1)\).
They build an internal representation of the input.
For numerical rating prediction, a linear output is a simple starting point.
The network’s output is \(\widehat y=f_L(x)\); training decides the values inside its weight matrices and bias vectors.
nn.Linear brick| object | shape |
|---|---|
| input \(x\) | \((B,8)\) |
| weight \(W\) | \((32,8)\) |
| bias \(b\) | \((32,)\) |
| output \(h\) | \((B,32)\) |
A Linear brick has \(8\cdot32+32=288\) learned numbers; the bias is broadcast across the batch.
model = nn.Sequential(
nn.Linear(8, 32), nn.ReLU(),
nn.Linear(32, 16), nn.ReLU(),
nn.Linear(16, 1),
)
Sequential is the baseplate: it snaps the bricks in order. ReLU keeps the shape; Linear changes it.
class MLP(nn.Module):
def __init__(self):
super().__init__()
torch.manual_seed(0) # pin initialization
self.net = nn.Sequential(
nn.Linear(8, 32), nn.ReLU(),
nn.Linear(32, 16), nn.ReLU(),
nn.Linear(16, 1),
)
def forward(self, x):
return self.net(x)init registers the bricks; forward defines the path. The notebook uses this class in both the pure-PyTorch and skorch sections.
| layer | role | learned parameters |
|---|---|---|
nn.Linear(d_in, d_out)
|
affine map \(xW^\top+b\) | \(d_{\rm in}d_{\rm out}+d_{\rm out}\) |
nn.ReLU()
|
elementwise nonlinearity | none |
nn.Sequential(…)
|
container that calls layers in order | none |
nn.Module
|
base class for a custom forward pass | depends on layers |
nn.Embedding(n, K)
|
lookup table for integer IDs | \(nK\) (next lecture) |
For the housing model, Linear + ReLU + Sequential are enough. Embedding will replace the one-hot lookup in NCF.
nn.Moduleclass ParallelMLP(nn.Module):
def __init__(self):
super().__init__()
self.up = nn.Sequential(
nn.Linear(8, 3), nn.ReLU(), nn.Linear(3, 1))
self.down = nn.Sequential(
nn.Linear(8, 2), nn.ReLU(), nn.Linear(2, 1))
def forward(self, x):
return self.up(x) + self.down(x)Module?Sequential is one straight path
Two branches cannot be expressed as a single Sequential.
The forward pass computes each branch, then adds them.
Use Sequential for a stack; use Module when the computation branches or combines inputs—as NCF will.
32 ≠ 16 → mat1 and mat2 shapes cannot be multiplied
Before training, check every brick’s output shape against the next brick’s input shape. The first dimension is the batch size; the second is the feature width.
| examples | who decides? | |
|---|---|---|
| Parameters \(\theta\) | \(W_\ell\) and \(b_\ell\) | The optimizer, using training data |
| Hyperparameters | \(L\), \(d_\ell\), \(A\), \(\eta\), batch size, epochs | We choose; validation compares candidates |
More learned numbers and more flexibility.
Training error usually falls; validation decides whether the extra capacity helps.
Architecture choices define the model family; training only changes the parameters inside it.
With enough hidden units, a feedforward network can approximate a broad class of continuous functions on a bounded region.
It does not promise that our data, optimizer, or validation set will find a good approximation.
The theorem explains why the model family is expressive. Generalization still has to be measured on held-out ratings.
The optimizer needs a scalar that says how wrong the current model is.
nn.MSELoss
Squared error between the prediction and the observed target.
The loss is for the optimizer; metrics such as RMSE and MAE are for us.
| component | choice in the example |
|---|---|
| Data | California housing: 8 numerical features; target is median house value |
| Model | PyTorch MLP with ReLU hidden layers and a linear output |
| Loss | Mean squared error, used by the optimizer |
| Metrics | RMSE and MAE, reported for interpretation |
| Preprocessing |
Standardize features; wrap tensors in a DataLoader; keep the test set untouched
|
The loss is what training minimizes. A metric is what we report; they need not be the same function.
Compute the loss on a mini-batch, backpropagate, and take a step.
One mini-batch gives a noisy gradient; the learning rate sets the step size.
“SGD” in practice means mini-batch SGD: many small updates, not one update on the full dataset.
The framework computes these derivatives automatically. We still need to specify the model, loss, optimizer, and data.
Forward stores the intermediate activations; backward reuses them to fill one gradient for every weight and bias.
loss_fn = nn.MSELoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
for epoch in range(epochs):
model.train()
for xb, yb in train_loader:
optimizer.zero_grad()
loss = loss_fn(model(xb), yb)
loss.backward()
optimizer.step()
zero_grad clears old gradients; backward applies autograd; step updates \(\theta\leftarrow\theta-\eta\nabla_\theta J\).
.fitfrom skorch import NeuralNetRegressor
model = NeuralNetRegressor(
module=MLP,
criterion=nn.MSELoss,
optimizer=torch.optim.SGD,
max_epochs=100,
lr=0.01,
batch_size=64,
iterator_train__shuffle=True,
)
model.fit(X_train, y_train)
y_pred = model.predict(X_test)
skorch is a thin sklearn wrapper: .fit runs the loop above; .history stores the training and validation curves.
Too small: slow or stuck. Too large: oscillates or diverges. Start with 0.01 or 0.001.
Larger batches give smoother gradients but use more memory; smaller batches update more often.
Reshuffle each epoch so the model does not memorize the order of the training rows.
One epoch uses every training row once on average; validation decides how many epochs are useful.
“SGD” in practice means mini-batch SGD. Tune the learning rate and batch size on validation, never on the test set.
Training loss only says how well the model fits the rows it has already seen.
Evaluate on held-out rows, track the curve, and keep the test set for the final report.
Training loss is not the goal. Validation decides which model to report.
The optimizer uses these answers to change \(\theta\).
Never use these target values in gradient updates.
A network can keep lowering training loss while validation loss starts rising. That pattern signals overfitting.
The same U-shape appears when we increase model complexity. More flexibility helps only until validation stops improving.
Every data fold is validation once; the other folds train.
Usually more stable than one holdout split.
K-fold uses each row for validation once; the test set remains untouched.
Accurate model selection, but expensive for large networks.
Standard for deep learning: fast, though noisier.
Fit each candidate on the CV folds; keep the test set for the final report.
Cross-validation is still correct; a single holdout is faster but noisier. GridSearchCV automates the comparison.
GridSearchCVfrom sklearn.model_selection import GridSearchCV
search_net = NeuralNetRegressor(
module=MLP,
criterion=nn.MSELoss,
optimizer=torch.optim.SGD,
batch_size=64,
)
search_net.set_params(train_split=False, verbose=0)
param_grid = {
'lr': [0.01],
'max_epochs': [80, 100],
'batch_size': [32, 64],
}
gs = GridSearchCV(
search_net, param_grid,
cv=3, scoring='neg_root_mean_squared_error',
refit=True,
)
gs.fit(X_train, y_train)
final_net = gs.best_estimator_
refit=True refits the best settings on all training rows; gs.best_estimator_ is the tuned model used for the final test prediction.
How many transformations and hidden units?
How do training updates behave?
When should we stop, and which design predicts best?
Start with a small network and a held-out validation set; increase complexity only when the evidence supports it.
| symptom | likely cause | next check |
|---|---|---|
| loss oscillates | learning rate too large | reduce step size |
| loss hardly moves | learning rate too small or inputs poorly scaled | inspect features and gradients |
| train improves, validation worsens | overfitting | simpler model or more data |
| validation looks excellent | possible leakage | audit the split and preprocessing |
| loss becomes NaN | unstable update or unscaled inputs | reduce step size; standardize features |
Debug with both training and validation evidence—not a single final accuracy number.
| choice | starting point |
|---|---|
| Output layer | Match the task: linear for regression; sigmoid or softmax for classification |
| Hidden layers | First layer ≈ half the input width; halve thereafter (128, 64, 32, …) |
| Activation | ReLU is a good default |
| Epochs | Start with 20 to check convergence; then use 100 and choose by validation |
| Batch size | Powers of 2; start with 16–64, larger for big or imbalanced data |
| Inputs | Standardize numerical features before fitting |
These are starting points, not laws. Tune systematically and let validation choose.
Open nb/STAT3009_NN.ipynb and use the California housing data. Work in pairs for 20–25 minutes.
NeuralNetRegressor with batch size 64 for at most 50 epochs; compare validation RMSE.
Compare the training and validation curves. Which epoch had the best validation RMSE?
If not: check the learning rate and shapes.
If not: overfitting, leakage, or too little data.
Try learning rate, batch size, or depth; compare validation curves.
A model is not finished when training loss is low; it is finished when validation and the test set agree.
| question | in this lecture | implementation |
|---|---|---|
| Model | layers + activations |
nn.Module
|
| Hyperparameters | depth, width, lr, batch size | choices in Data / Model / Optimization |
| Criterion | MSE on training data |
nn.MSELoss
|
| Learned | weights + biases |
torch.optim.SGD
|
Next: use embeddings to turn user and item IDs into network inputs. That is neural collaborative filtering.
Choose bricks, check shapes, and wrap them in Sequential or Module.
Know what training learns and what validation chooses.
Gradient descent, mini-batches, and the chain rule behind backpropagation.
Use validation curves to compare models and keep the test set untouched.
Next: use embedding layers to turn user and item IDs into network inputs. That is neural collaborative filtering.
CUHK · STAT3009 · Neural Networks