CSCI 3151 — Foundations of Machine Learning
By the end of this module, you should be able to:
We can now train deep networks, using modern frameworks.
How do we make them generalize well and train stably in practice?
Think of four big levers:
💡 Idea (Srivastava et al. 2014; Gal and Ghahramani 2016):
At training time, randomly “drop” (set to 0) some hidden units.

Consider a hidden layer activation vector \(h \in \mathbb{R}^d\).
nn.Dropout): \[
\tilde{h}_i = \frac{m_i}{p} \, h_i
\]import torch
import torch.nn as nn
import torch.nn.functional as F
class DropoutMLP(nn.Module):
def __init__(self, input_dim, hidden_dim, num_classes, p_drop=0.5):
super().__init__()
self.fc1 = nn.Linear(input_dim, hidden_dim)
self.dropout1 = nn.Dropout(p=p_drop)
self.fc2 = nn.Linear(hidden_dim, hidden_dim)
self.dropout2 = nn.Dropout(p=p_drop)
self.out = nn.Linear(hidden_dim, num_classes)
def forward(self, x):
# x: (batch_size, input_dim)
x = F.relu(self.fc1(x))
x = self.dropout1(x) # only active in model.train() mode
x = F.relu(self.fc2(x))
x = self.dropout2(x)
return self.out(x)nn.Dropout automatically:
Goal: see how dropout changes generalization on a small, overparameterized model.
import numpy as np
from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
import torch
from torch.utils.data import TensorDataset, DataLoader
import matplotlib.pyplot as plt
# Generate data
X, y = make_moons(n_samples=2000, noise=0.25, random_state=3151)
X_train, X_val, y_train, y_val = train_test_split(
X, y, test_size=0.3, stratify=y, random_state=0
)
scaler = StandardScaler().fit(X_train)
X_train = scaler.transform(X_train)
X_val = scaler.transform(X_val)
X_train_t = torch.tensor(X_train, dtype=torch.float32)
y_train_t = torch.tensor(y_train, dtype=torch.long)
X_val_t = torch.tensor(X_val, dtype=torch.float32)
y_val_t = torch.tensor(y_val, dtype=torch.long)
train_ds = TensorDataset(X_train_t, y_train_t)
val_ds = TensorDataset(X_val_t, y_val_t)
train_loader = DataLoader(train_ds, batch_size=64, shuffle=True)
val_loader = DataLoader(val_ds, batch_size=256)def train_model(model, train_loader, val_loader, epochs=50, lr=1e-2, device="cpu"):
model.to(device)
opt = torch.optim.Adam(model.parameters(), lr=lr)
criterion = nn.CrossEntropyLoss()
history = {"train_loss": [], "val_loss": [], "train_acc": [], "val_acc": []}
for epoch in range(epochs):
model.train()
running_loss, correct, total = 0.0, 0, 0
for xb, yb in train_loader:
xb, yb = xb.to(device), yb.to(device)
opt.zero_grad()
logits = model(xb)
loss = criterion(logits, yb)
loss.backward()
opt.step()
running_loss += loss.item() * xb.size(0)
preds = logits.argmax(dim=1)
correct += (preds == yb).sum().item()
total += xb.size(0)
train_loss = running_loss / total
train_acc = correct / total
# Validation
model.eval()
val_loss, correct, total = 0.0, 0, 0
with torch.no_grad():
for xb, yb in val_loader:
xb, yb = xb.to(device), yb.to(device)
logits = model(xb)
loss = criterion(logits, yb)
val_loss += loss.item() * xb.size(0)
preds = logits.argmax(dim=1)
correct += (preds == yb).sum().item()
total += xb.size(0)
val_loss /= total
val_acc = correct / total
history["train_loss"].append(train_loss)
history["val_loss"].append(val_loss)
history["train_acc"].append(train_acc)
history["val_acc"].append(val_acc)
return historyAblation n.: systematically removing or disabling a component of a model (or training recipe) to see how much it actually matters for performance.
# Without dropout
plain_mlp = DropoutMLP(input_dim=2, hidden_dim=64, num_classes=2, p_drop=0.0)
hist_plain = train_model(plain_mlp, train_loader, val_loader, epochs=80)
# With dropout
drop_mlp = DropoutMLP(input_dim=2, hidden_dim=64, num_classes=2, p_drop=0.5)
hist_drop = train_model(drop_mlp, train_loader, val_loader, epochs=80)
# Make a small summary table
import pandas as pd
summary = pd.DataFrame({
"model": ["plain", "dropout"],
"final_train_acc": [
hist_plain["train_acc"][-1],
hist_drop["train_acc"][-1],
],
"final_val_acc": [
hist_plain["val_acc"][-1],
hist_drop["val_acc"][-1],
]
})
summary| model | final_train_acc | final_val_acc | |
|---|---|---|---|
| 0 | plain | 0.945000 | 0.936667 |
| 1 | dropout | 0.940714 | 0.930000 |
plt.figure(figsize=(6, 4))
plt.plot(hist_plain["train_acc"], label="train (plain)")
plt.plot(hist_plain["val_acc"], label="val (plain)", linestyle="--")
plt.plot(hist_drop["train_acc"], label="train (dropout)")
plt.plot(hist_drop["val_acc"], label="val (dropout)", linestyle="--")
plt.xlabel("Epoch")
plt.ylabel("Accuracy")
plt.title("Two Moons: Dropout vs Plain MLP")
plt.legend()
plt.tight_layout()
plt.show()
Takeaways
👍 Short rule of thumb
If training accuracy never gets high, and you’re using dropout, consider lowering dropout or removing it.
Key problem:
Deep nets are sensitive to the scale / distribution of activations.
BatchNorm solution (Ioffe and Szegedy 2015):

Given activations \(x_1, \dots, x_m\) in a mini-batch (for one feature channel):
Training vs inference
Example for a fully-connected layer:
class DeepPlainMLP(nn.Module):
def __init__(self, input_dim=2, hidden_dim=128, num_classes=2):
super().__init__()
self.fc1 = nn.Linear(input_dim, hidden_dim)
self.fc2 = nn.Linear(hidden_dim, hidden_dim)
self.fc3 = nn.Linear(hidden_dim, hidden_dim)
self.fc4 = nn.Linear(hidden_dim, hidden_dim)
self.fc5 = nn.Linear(hidden_dim, hidden_dim)
self.out = nn.Linear(hidden_dim, num_classes)
def forward(self, x):
x = F.relu(self.fc1(x))
x = F.relu(self.fc2(x))
x = F.relu(self.fc3(x))
x = F.relu(self.fc4(x))
x = F.relu(self.fc5(x))
return self.out(x)
class DeepBNMLP(nn.Module):
def __init__(self, input_dim=2, hidden_dim=128, num_classes=2):
super().__init__()
self.fc1 = nn.Linear(input_dim, hidden_dim)
self.bn1 = nn.BatchNorm1d(hidden_dim)
self.fc2 = nn.Linear(hidden_dim, hidden_dim)
self.bn2 = nn.BatchNorm1d(hidden_dim)
self.fc3 = nn.Linear(hidden_dim, hidden_dim)
self.bn3 = nn.BatchNorm1d(hidden_dim)
self.fc4 = nn.Linear(hidden_dim, hidden_dim)
self.bn4 = nn.BatchNorm1d(hidden_dim)
self.fc5 = nn.Linear(hidden_dim, hidden_dim)
self.bn5 = nn.BatchNorm1d(hidden_dim)
self.out = nn.Linear(hidden_dim, num_classes)
def forward(self, x):
x = F.relu(self.bn1(self.fc1(x)))
x = F.relu(self.bn2(self.fc2(x)))
x = F.relu(self.bn3(self.fc3(x)))
x = F.relu(self.bn4(self.fc4(x)))
x = F.relu(self.bn5(self.fc5(x)))
return self.out(x)nn.BatchNorm2d(channels) after convolution layers.nn.BatchNorm1d + careful reshaping, or alternatives like LayerNorm.We’ll use the same two-moons data but a deeper network and a slightly higher learning rate to expose instability.
# ----------------- training with SGD (larger LR) ---------------------------
def train_model_sgd(model, train_loader, val_loader, epochs=80, lr=0.5, device="cpu"):
model.to(device)
opt = torch.optim.SGD(model.parameters(), lr=lr)
criterion = nn.CrossEntropyLoss()
history = {"train_loss": [], "val_loss": []}
for epoch in range(epochs):
model.train()
running_loss, total = 0.0, 0
for xb, yb in train_loader:
xb, yb = xb.to(device), yb.to(device)
opt.zero_grad()
logits = model(xb)
loss = criterion(logits, yb)
loss.backward()
opt.step()
running_loss += loss.item() * xb.size(0)
total += xb.size(0)
train_loss = running_loss / total
# validation
model.eval()
val_loss, total = 0.0, 0
with torch.no_grad():
for xb, yb in val_loader:
xb, yb = xb.to(device), yb.to(device)
logits = model(xb)
loss = criterion(logits, yb)
val_loss += loss.item() * xb.size(0)
total += xb.size(0)
val_loss /= total
history["train_loss"].append(train_loss)
history["val_loss"].append(val_loss)
return historyX, y = make_moons(n_samples=4000, noise=0.25, random_state=3151)
X_train, X_val, y_train, y_val = train_test_split(
X, y, test_size=0.3, stratify=y, random_state=0
)
X_train_t = torch.tensor(X_train, dtype=torch.float32)
y_train_t = torch.tensor(y_train, dtype=torch.long)
X_val_t = torch.tensor(X_val, dtype=torch.float32)
y_val_t = torch.tensor(y_val, dtype=torch.long)
train_ds = TensorDataset(X_train_t, y_train_t)
val_ds = TensorDataset(X_val_t, y_val_t)
train_loader = DataLoader(train_ds, batch_size=64, shuffle=True)
val_loader = DataLoader(val_ds, batch_size=256)
plain = DeepPlainMLP()
bn = DeepBNMLP()
hist_plain_deep = train_model_sgd(plain, train_loader, val_loader, epochs=80, lr=0.5)
hist_bn_deep = train_model_sgd(bn, train_loader, val_loader, epochs=80, lr=0.5)plt.figure(figsize=(6, 4))
plt.plot(hist_plain_deep["train_loss"], label="train (plain)")
plt.plot(hist_plain_deep["val_loss"], label="val (plain)", linestyle="--")
plt.plot(hist_bn_deep["train_loss"], label="train (BatchNorm)")
plt.plot(hist_bn_deep["val_loss"], label="val (BatchNorm)", linestyle="--")
plt.xlabel("Epoch")
plt.ylabel("Loss")
plt.title("Deep MLP: BatchNorm vs Plain (SGD, high LR)")
plt.legend()
plt.tight_layout()
plt.show()
When adding dropout / BatchNorm, always look at:
Rough intuition:
Informal effect:
Warning
This is not a guarantee of good gradients—but empirically it helps many architectures.
Common, simple tricks you’ll see in practice:
AdamW).Warning
Conceptual (MCQ)
Dropout primarily helps by:
Short answer
In one sentence, what are the two learned parameters in BatchNorm and what do they do?
Conceptual (MCQ)
You add dropout with \(p=0.5\) to a small model on a tiny dataset. Training and validation accuracy both drop sharply and stay low. What is the most likely diagnosis?
Short numeric reasoning
Suppose a hidden unit has activation \(h = 2.0\). With inverted dropout keep probability \(p = 0.5\), what are the possible training-time activations for this unit and their probabilities?
