
CSCI 3151 — Foundations of Machine Learning
By the end of this module, you should be able to:
Recall the supervised learning pipeline:
Optimization is Step 3:
Conceptual tradeoffs:
Gradient-based methods are a sweet spot:
Consider a smooth function \(J(w)\) of a single parameter \(w\).
Gradient descent update (1D):
\[ w_{t+1} = w_t - \eta \, J'(w_t), \]
where \(\eta > 0\) is the learning rate (step size).
Intuition:

In \(d\) dimensions with parameter vector \(w \in \mathbb{R}^d\), we use the gradient vector
\[ \nabla_w J(w) = \left( \frac{\partial J}{\partial w_1}, \dots, \frac{\partial J}{\partial w_d} \right). \]
Batch gradient descent update:
\[ w_{t+1} = w_t - \eta \, \nabla_w J(w_t). \]
For a simple convex, smooth function, gradient descent with a small enough learning rate:
For this course, remember:
Consider:
\[ J(w) = \frac{1}{2} a w^2, \quad a > 0. \]
where \(a > 0\) is a constant controlling the curvature.
\[ w_{t+1} = w_t - \eta a w_t = (1 - \eta a) w_t. \]
Iterating:
\[ w_t = (1 - \eta a )^t w_0. \]
Convergence condition (sketch):
\[ -1 < 1 - \eta a < 1 \quad \Rightarrow \quad 0 < \eta a < 2 \quad \Rightarrow \quad 0 < \eta < 2/a. \]
Takeaways:
You can think of many ML losses as generalized quadratics near an optimum, so similar intuition applies.
For an average loss over data:
\[ J(w) = \frac{1}{n} \sum_{i=1}^n \ell_i(w), \]
batch gradient descent uses the full sum each step.
\[ w_{t+1} = w_t - \eta \, \nabla_w \ell_{i_t}(w_t). \]
Characteristics:
\[ g_t = \frac{1}{\lvert B_t \rvert} \sum_{i \in B_t} \nabla_w \ell_i(w_t), \quad w_{t+1} = w_t - \eta g_t. \]
Tradeoffs:
\[ v_{t+1} = \beta v_t + \nabla J(w_t), \quad w_{t+1} = w_t - \eta v_{t+1}, \]
where: \(\eta\) is the learning rate and \(\beta \in [0, 1)\) is the momentum parameter.
We’ll visualize this on a simple “elongated bowl” loss.
Goal: use per-parameter learning rates that adapt over time.
Examples:
Adam update (elementwise):
\[ m_{t} = \beta_1 m_{t-1} + (1 - \beta_1) g_t, \quad v_{t} = \beta_2 v_{t-1} + (1 - \beta_2) g_t^2 \]
\[ \hat m_t = \frac{m_t}{1 - \beta_1^t}, \quad \hat v_t = \frac{v_t}{1 - \beta_2^t}, \quad w_{t+1} = w_t - \eta \frac{\hat m_t}{\sqrt{\hat v_t} + \varepsilon}. \]
📝 Notes:
Too large learning rate
Too small learning rate
Diagnosis tool: plot loss vs iteration (and sometimes gradient norm):
We’ll fit a simple line \(y \approx w_0 + w_1 x\) with explicit gradient descent, and compare to the closed-form solution.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
rng = np.random.default_rng(3151)
# Generate synthetic data: y = 3x + 1 + noise
n = 80
X = rng.uniform(-2, 2, size=(n, 1))
true_w0, true_w1 = 1.0, 3.0
noise = rng.normal(0, 0.8, size=n)
y = true_w0 + true_w1 * X.ravel() + noise
# Add bias column for w0
X_design = np.c_[np.ones_like(X), X] # shape (n, 2)
X[:5], y[:5](array([[ 0.13866131],
[-1.7206137 ],
[ 0.58167979],
[-1.75712451],
[ 1.86113116]]),
array([ 1.98278139, -4.34254563, 2.87329754, -4.45654002, 5.22714618]))
def mse_loss(w, X, y):
# Mean squared error loss for linear regression.
y_pred = X @ w
return np.mean((y - y_pred) ** 2)
def mse_grad(w, X, y):
# Gradient of MSE loss wrt w.
y_pred = X @ w
return -2.0 * (X.T @ (y - y_pred)) / len(y)
# Initialize
w = np.zeros(2)
eta = 0.05
num_iters = 200
loss_history = []
for t in range(num_iters):
loss = mse_loss(w, X_design, y)
loss_history.append(loss)
g = mse_grad(w, X_design, y)
w = w - eta * g
w, loss_history[-1](array([1.04384639, 3.0594615 ]), 0.616479536206361)


Design choices & subtleties:
We’ll implement a simple mini-batch SGD optimizer for logistic regression.
from sklearn.datasets import make_classification
from sklearn.metrics import accuracy_score
# Synthetic binary classification
X_cls, y_cls = make_classification(
n_samples=800,
n_features=4,
n_informative=3,
n_redundant=0,
random_state=3151,
)
# Add bias column
X_cls_design = np.c_[np.ones((X_cls.shape[0], 1)), X_cls]
# Train/test split
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(
X_cls_design, y_cls, test_size=0.3, random_state=3151
)
X_train.shape, X_test.shape((560, 5), (240, 5))
def sigmoid(z):
return 1.0 / (1.0 + np.exp(-z))
def logistic_loss_and_grad(w, X, y):
# Negative log-likelihood (average) for logistic regression and its gradient.
scores = X @ w
probs = sigmoid(scores)
# Binary cross-entropy loss
eps = 1e-12
loss = -np.mean(y * np.log(probs + eps) + (1 - y) * np.log(1 - probs + eps))
grad = X.T @ (probs - y) / len(y)
return loss, grad
def sgd_logistic(X, y, eta=0.1, batch_size=32, num_epochs=20, rng=None):
rng = np.random.default_rng(rng)
n, d = X.shape
w = np.zeros(d)
history = []
for epoch in range(num_epochs):
# Shuffle indices each epoch
indices = rng.permutation(n)
for start in range(0, n, batch_size):
batch_idx = indices[start:start + batch_size]
X_batch = X[batch_idx]
y_batch = y[batch_idx]
loss, grad = logistic_loss_and_grad(w, X_batch, y_batch)
w = w - eta * grad
history.append(loss)
return w, history
w_sgd, loss_hist_sgd = sgd_logistic(
X_train, y_train,
eta=0.1,
batch_size=32,
num_epochs=30,
rng=3151,
)
len(loss_hist_sgd), loss_hist_sgd[:5](540,
[0.6931471805579453,
0.6848241436460908,
0.673225537662632,
0.6782243265743305,
0.6727666003601709])
# Loss vs update step
plt.figure(figsize=(6, 4))
plt.plot(loss_hist_sgd)
plt.xlabel("Update step")
plt.ylabel("Mini-batch loss")
plt.title("Mini-batch SGD for logistic regression")
plt.tight_layout()
plt.show()
# Train and test accuracy
def predict_logistic(w, X):
return (sigmoid(X @ w) >= 0.5).astype(int)
y_train_pred = predict_logistic(w_sgd, X_train)
y_test_pred = predict_logistic(w_sgd, X_test)
train_acc = accuracy_score(y_train, y_train_pred)
test_acc = accuracy_score(y_test, y_test_pred)
train_acc, test_acc
Discussion:
eta and batch_size values:
When using gradient-based optimization, we should monitor:
These checks connect directly back to evaluation & bias–variance and regularization modules.
Although optimization sounds purely technical, there are risks:
So far: we wrote updates like
\[ w_{t+1} = w_t - \eta \nabla J(w_t) \quad\text{and}\quad \text{Adam updates with } m_t, v_t. \]
In practice, you’ll usually see these wrapped in a library optimizer.
import torch
import torch.nn as nn
model = MyNeuralNet() # your model
criterion = nn.CrossEntropyLoss() # or MSELoss, etc.
# Option 1: SGD + momentum
sgd_opt = torch.optim.SGD(
model.parameters(),
lr=0.1,
momentum=0.9, # like "heavy-ball" momentum
weight_decay=1e-4, # L2 regularization
)
# Option 2: Adam
adam_opt = torch.optim.Adam(
model.parameters(),
lr=1e-3, # often smaller than SGD lr
weight_decay=1e-4, # decoupled "AdamW" variant is common
)
# Typical training loop (same structure for both)
optimizer = adam_opt # or sgd_opt
for X_batch, y_batch in train_loader:
optimizer.zero_grad() # reset gradients
y_pred = model(X_batch) # forward pass
loss = criterion(y_pred, y_batch)
loss.backward() # compute gradients
optimizer.step() # apply update ruleNote
In most real projects, “which optimizer? which lr?” is tuned by experiments + experience, not by pure theory.
This module gives you the foundational tools to start thinking about such questions.
(MCQ) Which of the following is not a typical reason to prefer mini-batch SGD over full-batch gradient descent?
A. Lower memory usage per update
B. Faster updates per unit of data processed
C. Ability to compute the exact gradient at each step
D. Better scalability to large datasets
(MCQ) For the 1D quadratic \(J(w) = \frac{1}{2} a w^2\) with \(a > 0\), which range of learning rates \(\eta\) guarantees convergence of gradient descent?
A. \(\eta > 2/a\)
B. \(0 < \eta < 2/a\)
C. \(0 < \eta < a/2\)
D. Any \(\eta > 0\)
(Concept) Briefly explain the difference between batch gradient descent and stochastic gradient descent in terms of how the gradient is computed.
(Numeric) Suppose we run gradient descent on \(J(w) = w^2\) with learning rate \(\eta = 0.25\) and initial value \(w_0 = 4\).
Compute \(w_1\) and \(w_2\).
