Implementing Gradient Descent From Scratch: NumPy vs PyTorch
Problem 1: Linear Regression with Gradient Descent
The Problem
def linear_regression_gradient_descent(X: np.ndarray, y: np.ndarray, alpha: float, iterations: int) -> np.ndarray:
"""
Perform linear regression using gradient descent.
Args:
X: Feature matrix of shape (m, n) where first column is all ones (for intercept)
y: Target vector of shape (m,)
alpha: Learning rate
iterations: Number of gradient descent iterations
Returns:
Learned weights as a 1D array of shape (n,)
"""
X is an (m, n) feature matrix where m is the number of samples and n is the number of features (the first column is all 1s, which lets a single weight vector also learn the intercept). y is the target vector, alpha is the learning rate, and iterations is how many gradient descent steps to run.
The Math
This problem uses the standard textbook cost function with a 1/2 factor baked in:
The 1/2 is there for a reason: when you differentiate a square, the exponent 2 pops out front by the chain rule. Pre-multiplying by 1/2 makes that 2 cancel out cleanly:
So the gradient descent update rule is:
gradient = (1/m) * X.T @ error
theta = theta - alpha * gradient
NumPy Implementation (Manual Gradient)
import numpy as np
def linear_regression_gradient_descent(X: np.ndarray, y: np.ndarray, alpha: float, iterations: int) -> np.ndarray:
m, n = X.shape
y = y.reshape(-1, 1) # Ensure y is a column vector (m, 1)
theta = np.zeros((n, 1)) # Initialize weights to zeros
for _ in range(iterations):
y_pred = X @ theta # Predicted values, shape (m, 1)
error = y_pred - y # Prediction error
gradient = (1 / m) * (X.T @ error) # Gradient of cost w.r.t. theta
theta = theta - alpha * gradient # Update rule
return theta.flatten()
Why reshape y and theta into column vectors? NumPy treats a (m,) 1D array and a (m, 1) 2D array differently under broadcasting. If y_pred (shape (m, 1)) is subtracted from a 1D y (shape (m,)), broadcasting can silently produce an unintended (m, m) matrix instead of an elementwise (m, 1) difference. Reshaping avoids this class of bug entirely.
PyTorch Implementation — Two Ways
Version A: Manual Gradient (mirrors the NumPy code)
import torch
def linear_regression_gradient_descent_torch(X: torch.Tensor, y: torch.Tensor, alpha: float, iterations: int) -> torch.Tensor:
m, n = X.shape
y = y.reshape(-1, 1)
theta = torch.zeros((n, 1), dtype=X.dtype)
for _ in range(iterations):
y_pred = X @ theta
error = y_pred - y
gradient = (1 / m) * (X.T @ error)
theta = theta - alpha * gradient
return theta.flatten()
This is a literal translation of the NumPy math into PyTorch tensor operations — no autograd involved.
Version B: PyTorch Autograd
import torch
def linear_regression_gradient_descent_autograd(X: torch.Tensor, y: torch.Tensor, alpha: float, iterations: int) -> torch.Tensor:
m, n = X.shape
y = y.reshape(-1, 1)
theta = torch.zeros((n, 1), dtype=X.dtype, requires_grad=True) # Track gradients
for _ in range(iterations):
y_pred = X @ theta
loss = torch.mean((y_pred - y) ** 2) / 2 # 1/(2m) * sum(error^2), matches manual formula
loss.backward() # Autograd computes dLoss/dtheta automatically
with torch.no_grad(): # Don't track this update step
theta -= alpha * theta.grad
theta.grad.zero_() # Reset gradient before next iteration
return theta.detach().flatten() # Detach from computation graph before returning
Instead of deriving gradient = (1/m) * X.T @ error by hand, we just define the cost function (loss) and let loss.backward() compute the derivative automatically. Note the / 2 at the end of the loss — this makes the cost function match 1/(2m) * sum(error^2) exactly, so autograd produces the same 1/m gradient we derived manually.
Three details that matter here:
requires_grad=True— tells PyTorch to track operations onthetaso it can be differentiated later.with torch.no_grad():— the parameter update step itself shouldn’t be tracked by autograd; without this you’d get errors or an unnecessarily growing computation graph.theta.grad.zero_()— PyTorch accumulates gradients by default. Forgetting to zero them out means each iteration’s gradient gets added on top of the previous one, silently corrupting the results.
A Debugging Detour: RuntimeError and grad_fn
Early on, comparing output against an expected tensor produced:
expected: tensor([1., 2.])
got: tensor([1., 2.], grad_fn=<DivBackward0>)
The values were numerically correct, but the returned tensor still carried its autograd history (grad_fn). The fix is to call .detach() before returning:
return theta.detach().flatten()
.detach() strips the computation-graph “history” off a tensor and leaves just the plain values — appropriate once training is done and you only need the final numbers.
Problem 2: Batch / Stochastic / Mini-Batch Gradient Descent
The Problem
def gradient_descent(X, y, weights, learning_rate, n_epochs, batch_size=1, method='batch'):
"""
Perform gradient descent optimization.
Args:
X: Feature matrix of shape (m, n)
y: Target values of shape (m,)
weights: Initial weights of shape (n,)
learning_rate: Step size for gradient descent
n_epochs: Number of complete passes through the dataset
batch_size: Size of batches for mini-batch gradient descent (default: 1)
method: Type of gradient descent ('batch', 'stochastic', or 'mini_batch')
Returns:
Optimized weights
"""
This problem generalizes Problem 1 into three variants:
| Method | Samples per update | Updates per epoch |
|---|---|---|
| Batch | all m samples |
1 |
| Stochastic (SGD) | 1 sample | m |
| Mini-batch | batch_size samples |
m / batch_size |
An epoch is one full pass through the dataset — how many weight updates happen within that pass depends on the method.
The Math — A Different Convention
This problem defines its cost function without the 1/2 factor:
Differentiating this, the 2 from the power rule has nothing to cancel against, so it survives into the gradient:
This is an important lesson: the 1/2 factor is a convention, not a law of nature. Different textbooks/problems define MSE differently, and the “correct” gradient coefficient depends entirely on how the cost function was defined. When in doubt, the fastest way to find the right coefficient is to test candidate formulas against a known expected output.
For reference, here’s how that coefficient was verified empirically against expected = [1.14905239, 0.56176776]:
| Candidate coefficient | Result |
|---|---|
1/m |
[1.17298353, 0.49076373] ❌ |
1/(2m) |
[1.1697732, 0.44546986] ❌ |
2/m |
[1.14905239, 0.56176776] ✅ |
NumPy Implementation (Manual Gradient, All Three Methods)
import numpy as np
def gradient_descent(X, y, weights, learning_rate, n_epochs, batch_size=1, method='batch'):
m, n = X.shape
if method == 'batch':
for epoch in range(n_epochs):
y_pred = X @ weights
error = y_pred - y
gradient = (2 / m) * (X.T @ error)
weights = weights - learning_rate * gradient
return weights.flatten()
elif method == 'stochastic':
for epoch in range(n_epochs):
for i in range(m):
Xi = X[i:i+1]
yi = y[i:i+1]
y_pred = Xi @ weights
error = y_pred - yi
gradient = 2 * (Xi.T @ error) # no averaging — single sample
weights = weights - learning_rate * gradient
return weights.flatten()
elif method == 'mini_batch':
for epoch in range(n_epochs):
for i in range(0, m, batch_size):
Xi = X[i:(i + batch_size)]
yi = y[i:(i + batch_size)]
y_pred = Xi @ weights
error = y_pred - yi
gradient = (2 / batch_size) * (Xi.T @ error)
weights = weights - learning_rate * gradient
return weights.flatten()
Key implementation detail — slicing: the mini-batch loop must slice X[i : i + batch_size], not X[i : i + 1]. The latter is a classic off-by-one-style bug: it always grabs exactly one sample regardless of batch_size, silently turning “mini-batch” into “stochastic” while also skipping most of the dataset.
Stochastic gradient’s coefficient (2, no 1/m) makes sense because, for a single sample, there’s no averaging to do — the “batch” is size 1, so the 1/m in the batch formula naturally becomes 1/1 = 1, leaving just the 2 from the power rule.
PyTorch Implementation — Manual Gradient
import torch
def gradient_descent(X: torch.Tensor, y: torch.Tensor, weights: torch.Tensor,
learning_rate: float, n_epochs: int,
batch_size: int = 1, method: str = 'batch') -> torch.Tensor:
X = torch.as_tensor(X, dtype=torch.float32)
y = torch.as_tensor(y, dtype=torch.float32)
weights = torch.as_tensor(weights, dtype=torch.float32).clone()
m, n = X.shape
if method == 'batch':
for epoch in range(n_epochs):
y_pred = X @ weights
error = y_pred - y
gradient = (2 / m) * (X.T @ error)
weights = weights - learning_rate * gradient
elif method == 'stochastic':
for epoch in range(n_epochs):
for i in range(m):
Xi = X[i:i+1]
yi = y[i:i+1]
y_pred = Xi @ weights
error = y_pred - yi
gradient = 2 * (Xi.T @ error)
weights = weights - learning_rate * gradient
elif method == 'mini_batch':
for epoch in range(n_epochs):
for i in range(0, m, batch_size):
Xi = X[i:(i + batch_size)]
yi = y[i:(i + batch_size)]
y_pred = Xi @ weights
error = y_pred - yi
gradient = (2 / batch_size) * (Xi.T @ error)
weights = weights - learning_rate * gradient
else:
raise ValueError(f"Unknown method: {method}")
return weights
This is a near 1:1 translation of the NumPy version — @ and .T work identically in PyTorch. Two additions matter for correctness:
torch.as_tensor(X, dtype=torch.float32): even though the type hint saysX: torch.Tensor, Python does not enforce type hints at runtime. IfXarrives as anint64tensor (very easy to get by accident —torch.tensor([[1,1],[2,1]])defaults toint64), matrix multiplication with afloat32weight tensor throws a dtype mismatch error.torch.as_tensor(..., dtype=torch.float32)is a defensive one-liner that normalizes dtype regardless of what came in, and is a no-op (no copy) if the tensor already matches..clone():torch.as_tensor()does not copy data when the input is already a tensor of the target dtype — it returns an alias pointing at the same memory. In the code above, weight updates are done via reassignment (weights = weights - ...), which never mutates the original memory, so.clone()isn’t strictly required here. But it’s cheap insurance: if the update logic is ever refactored to use an in-place operator (weights -= ...), an alias would silently corrupt the caller’s original tensor. This is the exact same category of bug thatdf.copy()guards against in pandas — both guard against unintended mutation through shared references.
PyTorch Implementation — Autograd
The manual version above never actually uses PyTorch’s signature feature: automatic differentiation. Here’s the same three methods, but letting loss.backward() compute every gradient instead of deriving 2/m, 2, and 2/batch_size by hand:
import torch
def gradient_descent(X: torch.Tensor, y: torch.Tensor, weights: torch.Tensor,
learning_rate: float, n_epochs: int,
batch_size: int = 1, method: str = 'batch') -> torch.Tensor:
X = torch.as_tensor(X, dtype=torch.float32)
y = torch.as_tensor(y, dtype=torch.float32)
weights = torch.as_tensor(weights, dtype=torch.float32).clone().requires_grad_(True)
m, n = X.shape
if method == 'batch':
for epoch in range(n_epochs):
y_pred = X @ weights
loss = torch.mean((y_pred - y) ** 2) # MSE over the full dataset
loss.backward() # autograd computes dLoss/dweights
with torch.no_grad():
weights -= learning_rate * weights.grad
weights.grad.zero_()
elif method == 'stochastic':
for epoch in range(n_epochs):
for i in range(m):
Xi = X[i:i+1]
yi = y[i:i+1]
y_pred = Xi @ weights
loss = torch.mean((y_pred - yi) ** 2) # MSE over a single sample
loss.backward()
with torch.no_grad():
weights -= learning_rate * weights.grad
weights.grad.zero_()
elif method == 'mini_batch':
for epoch in range(n_epochs):
for i in range(0, m, batch_size):
Xi = X[i:(i + batch_size)]
yi = y[i:(i + batch_size)]
y_pred = Xi @ weights
loss = torch.mean((y_pred - yi) ** 2) # MSE over the mini-batch
loss.backward()
with torch.no_grad():
weights -= learning_rate * weights.grad
weights.grad.zero_()
else:
raise ValueError(f"Unknown method: {method}")
return weights.detach()
Notice we never write 2/m, 2, or 2/batch_size anywhere — torch.mean((y_pred - y) ** 2) defines the cost, and loss.backward() derives the correct coefficient automatically for every method. This is the real value of autograd: it removes an entire category of bugs (getting the derivative’s constant factor wrong).
Verified numerically against the manual-gradient version, all three methods match to floating-point precision:
batch: manual [1.17298353, 0.49076373] with 1/m → wrong coefficient
manual [1.14905239, 0.56176776] with 2/m → ✅
autograd tensor([1.1491, 0.5618]) → ✅ matches
stochastic: manual [1.0507814, 0.83659454] → autograd [1.0508, 0.8366] ✅ matches
mini_batch: manual [1.10334065, 0.68329431] → autograd [1.1033, 0.6833] ✅ matches
A Second Debugging Detour: RuntimeError: element 0 of tensors does not require grad
Switching to autograd introduced a new class of bug. Running:
weights = torch.as_tensor(weights, dtype=torch.float32).clone() # missing .requires_grad_(True)
...
loss.backward()
throws:
RuntimeError: element 0 of tensors does not require grad and does not have a grad_fn
The cause: weights started life as torch.zeros(...), which defaults to requires_grad=False. Neither torch.as_tensor() nor .clone() changes that flag — if the input didn’t require grad, the output doesn’t either. Since weights.requires_grad was False, every downstream tensor (y_pred, loss) also had requires_grad=False and no grad_fn, so there was nothing for .backward() to differentiate through.
Fix: explicitly opt in with .requires_grad_(True):
weights = torch.as_tensor(weights, dtype=torch.float32).clone().requires_grad_(True)
Two smaller bugs surfaced alongside this one and are worth flagging since they’re easy to make:
weights.grad.zero()vsweights.grad.zero_()— PyTorch’s in-place methods use a trailing underscore by convention.zero()isn’t a valid method; it has to bezero_().loss = 2 * torch.mean((y_pred - y) ** 2)— manually multiplying the autograd-computed loss by an extra2reintroduces the exact class of bug autograd is supposed to eliminate: it silently doubles the gradient, and if the multiplier isn’t applied consistently across all three methods (as happened here — it was missing from themini_batchbranch), the three methods end up using different effective learning rates.
Takeaways
- The
1/2factor in MSE is a convention, not a rule. Always check what the expected output implies about which coefficient a specific problem wants, rather than assuming. - NumPy and PyTorch manual-gradient code translate almost 1:1 — the main differences are dtype handling (
torch.as_tensor) and defensive copying (.clone()), not the math itself. - Autograd’s value is removing manual derivative bugs, but it introduces its own failure mode: forgetting
requires_grad=Trueon the parameter you intend to differentiate, or forgetting.grad.zero_()between iterations (gradients accumulate by default). - Off-by-one slicing bugs are sneaky.
X[i:i+1]instead ofX[i:i+batch_size]still runs without error — it just silently changes what algorithm you’re actually implementing. .detach()before returning any tensor that was part of a training loop’s computation graph, or downstream code that compares it to a plain tensor will see a mismatch caused by the lingeringgrad_fn.
Comments