Approach: compute forward pass for a single fully-connected layer with ReLU: y = ReLU(XW + b). Compute analytical gradients via backprop for a small random batch, then estimate numerical gradients with centered finite differences (better accuracy). Compare using relative error per-parameter.python
import numpy as np
def relu(x):
return np.maximum(0, x)
def relu_grad(x):
return (x > 0).astype(x.dtype)
def grad_check(X, W, b, dout, eps=1e-5):
"""
X: (N, D_in)
W: (D_in, D_out)
b: (D_out,)
dout: upstream gradient wrt output (N, D_out), e.g., ones or random
eps: finite difference epsilon
Returns: max relative error and dict of errors
"""
# Forward
Z = X.dot(W) + b # (N, D_out)
Y = relu(Z)
# Analytical backward
dZ = dout * relu_grad(Z) # (N, D_out)
dW_anal = X.T.dot(dZ) / X.shape[0] # average over batch
db_anal = dZ.mean(axis=0)
# Numerical gradients
dW_num = np.zeros_like(W)
it = np.nditer(W, flags=['multi_index'], op_flags=['readwrite'])
while not it.finished:
idx = it.multi_index
orig = W[idx]
W[idx] = orig + eps
plus = relu(X.dot(W) + b)
loss_plus = np.sum(plus * dout) / X.shape[0]
W[idx] = orig - eps
minus = relu(X.dot(W) + b)
loss_minus = np.sum(minus * dout) / X.shape[0]
dW_num[idx] = (loss_plus - loss_minus) / (2 * eps)
W[idx] = orig
it.iternext()
db_num = np.zeros_like(b)
for j in range(b.size):
orig = b[j]
b[j] = orig + eps
plus = relu(X.dot(W) + b)
loss_plus = np.sum(plus * dout) / X.shape[0]
b[j] = orig - eps
minus = relu(X.dot(W) + b)
loss_minus = np.sum(minus * dout) / X.shape[0]
db_num[j] = (loss_plus - loss_minus) / (2 * eps)
b[j] = orig
# relative error
def rel_err(a, b): return np.max(np.abs(a - b) / (np.maximum(1e-8, np.abs(a) + np.abs(b))))
return {
'dW_max_rel_err': rel_err(dW_anal, dW_num),
'db_max_rel_err': rel_err(db_anal, db_num),
'dW_anal': dW_anal,
'dW_num': dW_num,
'db_anal': db_anal,
'db_num': db_num,
}
# Example usage
np.random.seed(0)
N, D_in, D_out = 4, 5, 3
X = np.random.randn(N, D_in)
W = np.random.randn(D_in, D_out) * 0.1
b = np.random.randn(D_out) * 0.1
dout = np.random.randn(N, D_out)
res = grad_check(X, W, b, dout)
print(res['dW_max_rel_err'], res['db_max_rel_err'])
Key assumptions and choices:- eps = 1e-5 (centered difference). Too large -> truncation error; too small -> numerical cancellation. 1e-5 is common for float64.- We average gradients over batch (consistent between analytic and numeric).- Use centered finite differences for better accuracy.Complexity:- Analytical: O(N * D_in * D_out) for forward/backprop.- Numerical: O(n_params * N * D_in * D_out) because each parameter perturbs a full forward pass. So numeric is expensive; test with small sizes.How to present results / next steps if teammate suspects a bug:- Report max and per-parameter relative errors and show heatmap or histogram comparing analytic vs numeric.- Thresholds: relative error < 1e-6–1e-4 usually acceptable; tune by dtype and eps.- If error large: - Check shapes and broadcasting, check whether you averaged/normalized consistently. - Check ReLU at zero: nondifferentiable; randomize inputs or use small perturbations to move away from exact zeros. - Use float64 to reduce numerical noise. - Run per-example gradients (no batch averaging) to localize issue. - Reduce epsilon slightly or try multiple eps values to ensure stability. - Add unit tests for simple cases (identity activation, linear layer) where analytic gradients are trivial.This provides reproducible evidence and actionable debugging steps.