Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Backpropagation by hand

Original study notes by Satish Bajagain, following Andrej Karpathy’s Zero to Hero. Comments, intermediate attempts and saved outputs are retained. Section titles are navigation additions.

Why understand the backward pass?

# we have a pretty decent understanding of the architecture of MLP
# but we are using loss.backward() directly as we should
# but what we want to do is remove loss.backward() and instead write our entire backward pass manually on the level of tensors
# but why? backprop: leaky abstraction
# its not that we can just stack up differential operations and hope our backward pass works
# sometimes, it might not work, or it might not work optimally, and you might want to know how it works under the hood if you want to debug it
# look at blog post
# flat tails of functions, you don't want to saturate them too much else the gradients will die: vanishing gradient case earlier discussed
# case of dead neurons
# and a bug that shows that the author doesn't understand backpropagation
# author tried to clip the loss at a certain max value, but they were hoping to clip the gradients at a maximum value instead
# causing outliers to be ignored cuz when you clip the loss of an outlier, you set its gradient to zero
# its just that if you know stuffs, you will avoid issues in the future
# we already covered autograd, and had written micrograd in lecture 1 itself
# but micrograd was an autograd engine only on the level of individual scalars
# we want to think of backprop on the level of tensors as well
# we want to better understand neural networks, what we do, and be better debuggers too
# we don't have to be nervous about what is hidden, its a good exercise too
# Today, writing backward pass by hand is not recommended, and no one does it
# but it was pervasive 10 yrs ago and standard to do backward pass by hand, he showed some research papers and examples of about 10 years ago
# after doing backward pass manually, we would use a gradient checker to make sure that the numerical estimate of the gradient
# agrees with the calculated gradient during actual backprop
# but today it is standard to use autograd engine
# so we're starting off where we left off last time, but instead of loss.backward(), we're going to do everything manually instead
# the only thing we're gonna use from pytorch will be torch.tensor to make everything efficient

Dataset and gradient checks

['emma', 'olivia', 'ava', 'isabella', 'sophia', 'charlotte', 'mia', 'amelia']
32033
{1: 'a', 2: 'b', 3: 'c', 4: 'd', 5: 'e', 6: 'f', 7: 'g', 8: 'h', 9: 'i', 10: 'j', 11: 'k', 12: 'l', 13: 'm', 14: 'n', 15: 'o', 16: 'p', 17: 'q', 18: 'r', 19: 's', 20: 't', 21: 'u', 22: 'v', 23: 'w', 24: 'x', 25: 'y', 26: 'z', 0: '.'}
27
torch.Size([182625, 3]) torch.Size([182625])
torch.Size([22655, 3]) torch.Size([22655])
torch.Size([22866, 3]) torch.Size([22866])
# Now that boilerplate is done, we get to the action

Breaking the forward pass into steps

4137
# old implementation
# # forward pass
#     emb = C[Xb] #embed the characters into vectors
#     embcat = emb.view(emb.shape[0], -1) #concatenate the vectors

#     # Linear layer
#     hpreact = embcat @ W1 # + b1 #hidden layer pre-activation #will be roughly unit Gaussian in the first iteration #removed b1 from here and above parameters list too

#     # Batch normalization (batchnorm) layer
#     # ----------------------------------------------------------------------------------------------------------------------------------
#     bnmeani = hpreact.mean(0, keepdim=True) #batch normalization mean for the ith iteration #will be roughly 0 for the first iteration
#     bnstdi = hpreact.std(0, keepdim=True) #will be roughly 1 for the first iteration
#     hpreact = bngain * (hpreact - bnmeani) / bnstdi  + bnbias 

#     # we don't want pytorch to build a graph for update of mean/sd
#     # this update is separate than the gradient based optimization during backprop
#     with torch.no_grad():
#         # it will be mostly what it already is, but receive a small update based on the direction of what the current mean/sd is
#         bnmean_running = 0.999 * bnmean_running + 0.001 * bnmeani
#         bnstd_running = 0.999 * bnstd_running + 0.001 * bnstdi
#     # ----------------------------------------------------------------------------------------------------------------------------------

#     # non-linearity
#     h = torch.tanh(hpreact) #hidden layer
#     logits = h @ W2 + b2 #output layer
#     loss = F.cross_entropy(logits, Yb) #loss function

#     # backward pass
#     for p in parameters:
#         p.grad = None
#     loss.backward()
tensor(3.3284, grad_fn=<NegBackward0>)
Loading...

Backpropagating the loss

logprobs        | exact: True  | approximate: True  | maxdiff: 0.0
# btw don't be confused, during coding, variable names are dlogprobs, but while computing derivatives i do dloss/dlogprobs, so variable dlogprobs = dloss/dlogprobs
probs           | exact: True  | approximate: True  | maxdiff: 0.0
counts_sum_inv  | exact: True  | approximate: True  | maxdiff: 0.0
counts = norm_logits.exp()
counts_sum = counts.sum(1, keepdims=True)
counts_sum_inv = counts_sum ** -1 #for some reason, doing 1/counts_sum can't get backprop to be a bit exact, problem in pytorch's backward pass implementation of division
probs = counts * counts_sum_inv
counts_sum      | exact: True  | approximate: True  | maxdiff: 0.0
counts_sum      | exact: True  | approximate: True  | maxdiff: 0.0
(torch.Size([32, 27]), torch.Size([32, 1]))
# asdfaowiue13euwoeias
# a11 a12 a13 ---> (a11 + a12 + a13 =) b1
# a21 a22 a23 ---> (a21 + a22 + a23 =) b2
# a31 a32 a33 ---> (a31 + a32 + a33 =) b3
# Here, b1 depends on a11, a12 and a13 only. so b1's derivative w.r.t them is 1, whereas b1's derivate w.r.t a21, a22, ..., a33 is 0 as it doesn't depend on them
# understand that the gradient flows horizontally from b1 to a11, a12, a13
# this is the same concept for counts.shape and counts_sum.shape. that sum column should replicate 27 times for each column of counts
# that's why we do: dcounts = torch.ones_line(counts) * dcounts_sum
counts          | exact: False | approximate: False | maxdiff: 0.005950985476374626
counts          | exact: True  | approximate: True  | maxdiff: 0.0
norm_logits     | exact: True  | approximate: True  | maxdiff: 0.0
logit_maxes     | exact: False | approximate: False | maxdiff: 0.03082321211695671
torch.Size([32, 27]) torch.Size([32, 27]) torch.Size([32, 1])
logit_maxes     | exact: True  | approximate: True  | maxdiff: 0.0
torch.Size([32, 1]) torch.Size([32, 27])
logits          | exact: False | approximate: False | maxdiff: 0.03082321025431156
logits          | exact: True  | approximate: True  | maxdiff: 0.0
<matplotlib.image.AxesImage at 0x7065d2ce4440>
<Figure size 640x480 with 1 Axes>

Matrix multiplication and bias

torch.Size([32, 27]) torch.Size([32, 64]) torch.Size([64, 27]) torch.Size([27])
h               | exact: True  | approximate: True  | maxdiff: 0.0
torch.Size([32, 27]) torch.Size([32, 64]) torch.Size([64, 27]) torch.Size([27])
W2              | exact: True  | approximate: True  | maxdiff: 0.0
torch.Size([32, 27]) torch.Size([32, 64]) torch.Size([64, 27]) torch.Size([27])
b2              | exact: True  | approximate: True  | maxdiff: 0.0

Through the nonlinearity

# that's how you backpropagate through a linear layer
hpreact         | exact: False | approximate: True  | maxdiff: 4.656612873077393e-10

Through batch normalization

torch.Size([32, 64]) torch.Size([32, 64]) torch.Size([1, 64]) torch.Size([1, 64])
bngain          | exact: False | approximate: True  | maxdiff: 1.862645149230957e-09
torch.Size([32, 64]) torch.Size([32, 64]) torch.Size([1, 64]) torch.Size([1, 64])
bnbias          | exact: False | approximate: True  | maxdiff: 3.725290298461914e-09
torch.Size([32, 64]) torch.Size([32, 64]) torch.Size([1, 64]) torch.Size([1, 64])
bnraw           | exact: False | approximate: True  | maxdiff: 9.313225746154785e-10
# Batch normalization (batchnorm) layer
bnmeani = (1 / n) * (hprebn.sum(0, keepdim=True))
bndiff = hprebn - bnmeani
bndiff2 = bndiff**2

bnvar = (1/(n-1)) * (bndiff2).sum(0, keepdim=True) #note: Bessel's correction (dividing by n-1, not n)
bnvar_inv = (bnvar + 1e-5)**-0.5 #that small value is epsilon, as you see in the formulas
bnraw = bndiff * bnvar_inv
torch.Size([32, 64]) torch.Size([32, 64]) torch.Size([1, 64])
bnvar_inv       | exact: False | approximate: True  | maxdiff: 3.725290298461914e-09
bnvar           | exact: False | approximate: True  | maxdiff: 4.656612873077393e-10
torch.Size([1, 64]) torch.Size([32, 64])
torch.Size([1, 64]) torch.Size([32, 64])
torch.Size([32, 64]) torch.Size([32, 64])
bndiff2         | exact: False | approximate: True  | maxdiff: 2.9103830456733704e-11
torch.Size([32, 64]) torch.Size([32, 64]) torch.Size([1, 64])
bndiff          | exact: False | approximate: False | maxdiff: 0.001129076350480318
bndiff          | exact: False | approximate: True  | maxdiff: 4.656612873077393e-10
torch.Size([32, 64]) torch.Size([32, 64]) torch.Size([1, 64])
torch.Size([1, 64]) torch.Size([1, 64])
bnmeani         | exact: False | approximate: True  | maxdiff: 2.7939677238464355e-09
torch.Size([32, 64]) torch.Size([1, 64])
torch.Size([32, 64]) torch.Size([32, 64])
hprebn          | exact: False | approximate: False | maxdiff: 0.007641538046300411
torch.Size([32, 64]) torch.Size([32, 64]) torch.Size([1, 64])
hprebn          | exact: False | approximate: True  | maxdiff: 4.656612873077393e-10

The first layer and embeddings

emb = C[Xb] #embed the characters into vectors
embcat = emb.view(emb.shape[0], -1) #concatenate the vectors

# Linear Layer 1
hprebn = embcat @ W1 + b1 #hidden layer pre-activation
torch.Size([32, 64]) torch.Size([32, 30]) torch.Size([30, 64]) torch.Size([64])
embcat          | exact: False | approximate: True  | maxdiff: 1.862645149230957e-09
torch.Size([32, 64]) torch.Size([32, 30]) torch.Size([30, 64]) torch.Size([64])
W1              | exact: False | approximate: True  | maxdiff: 7.450580596923828e-09
torch.Size([64]) torch.Size([64])
b1              | exact: False | approximate: True  | maxdiff: 3.725290298461914e-09
torch.Size([32, 30]) torch.Size([32, 3, 10])
emb             | exact: False | approximate: True  | maxdiff: 1.862645149230957e-09
torch.Size([32, 3, 10]) torch.Size([27, 10]) torch.Size([32, 3])
C               | exact: False | approximate: True  | maxdiff: 5.587935447692871e-09
# Forward:
# emb = C[Xb]
#
# C is the embedding lookup table:
#     C.shape = 27 x 10
#     27 possible characters, each represented by a 10-dimensional vector.
#
# Xb contains integer indices telling us which rows of C to pick:
#     Xb.shape = 32 x 3
#     32 examples, each containing 3 character indices.
#
# Therefore:
#     emb.shape = 32 x 3 x 10
#
# Each integer Xb[k, j] tells us:
#     "take row Xb[k, j] from C and put it at emb[k, j]"
#
# Example:
#     Xb[k,j] = 11
#     => emb[k,j] = C[11]
#
# During backprop, demb contains the gradient of the loss
# with respect to every embedding that was looked up.
#
# We now need to send those gradients back to the correct rows of C.

# dC = torch.zeros_like(C)   # start with zero gradient for every row of C

# for k in range(Xb.shape[0]):       # loop through the 32 examples
#     for j in range(Xb.shape[1]):   # loop through the 3 characters
#         ix = Xb[k, j]              # which row of C was used here?

#         # Forward did:
#         #     emb[k, j] = C[ix]
#         #
#         # So backward does the reverse:
#         #     dC[ix] += demb[k, j]
#         #
#         # We ADD because the same row of C can be looked up
#         # multiple times, and all those gradients must accumulate.
#         dC[ix] += demb[k, j]

# cmp('C', dC, C)

# think of forward pass as:
# C[11] ──────→ emb[0,0]
# C[14] ──────→ emb[0,1]
# C[11] ──────→ emb[1,0]

# suppose backward pass gives:
# demb[0,0] = [a,b,c,...]
# demb[0,1] = [d,e,f,...]
# demb[1,0] = [g,h,i,...]

# then:
# dC[11] += [a,b,c,...]
# dC[14] += [d,e,f,...]
# dC[11] += [g,h,i,...]

# ultimately:
# dC[11] = [a,b,c,...] + [g,h,i,...]
# This accumulation is crucial.
# Why? Because C[11] was used twice. Both uses contributed to the final loss, so both gradient contributions must flow back into the same parameter C[11].

# Forward: C → pick rows → emb
# Backward: demb → put gradients back into those rows → dC, adding gradients when a row was used multiple times.

Cross-entropy in one step

3.328404188156128 diff: -2.384185791015625e-07
logits          | exact: False | approximate: True  | maxdiff: 5.3551048040390015e-09
tensor([0.0716, 0.0875, 0.0189, 0.0464, 0.0208, 0.0935, 0.0253, 0.0346, 0.0173,
        0.0343, 0.0340, 0.0371, 0.0344, 0.0270, 0.0361, 0.0136, 0.0092, 0.0189,
        0.0152, 0.0553, 0.0452, 0.0225, 0.0250, 0.0660, 0.0633, 0.0265, 0.0207],
       grad_fn=<SelectBackward0>)
tensor([ 0.0716,  0.0875,  0.0189,  0.0464,  0.0208,  0.0935,  0.0253,  0.0346,
        -0.9827,  0.0343,  0.0340,  0.0371,  0.0344,  0.0270,  0.0361,  0.0136,
         0.0092,  0.0189,  0.0152,  0.0553,  0.0452,  0.0225,  0.0250,  0.0660,
         0.0633,  0.0265,  0.0207], grad_fn=<MulBackward0>)
tensor(2.7940e-09, grad_fn=<SumBackward0>)
# The gradients act like a push-pull system on the logits:
# - the correct character is pushed UP
# - incorrect characters are pushed DOWN
#
# The total gradient sums to 0, so the amount pushed up equals
# the total amount pushed down.
#
# The strength of each push/pull is proportional to the predicted probability:
# - confidently wrong prediction -> strong push/pull
# - nearly correct prediction -> weak push/pull
# - perfectly correct prediction -> essentially no push/pull
#
# These gradients then propagate backward to the weights and biases,
# gradually "tugging" the parameters toward better predictions.
#
# This is the intuitive picture of what cross-entropy loss does
# during the backward pass.
torch.Size([32, 27])
<matplotlib.image.AxesImage at 0x7065d132f750>
<Figure size 800x800 with 1 Axes>

BatchNorm in one step

# in the batchnorm paper, hprebn is x and hpreact is y
# in the backward pass we have dhpreact, and we'd like to produce dhprebn
# Exercise 3: backprop through batchnorm but all in one go
# to complete this challenge look at the mathematical expression of the output of batchnorm,
# take the derivative w.r.t. its input, simplify the expression, and just write it out

# forward pass

# before:
# bnmeani = 1/n*hprebn.sum(0, keepdim=True)
# bndiff = hprebn - bnmeani
# bndiff2 = bndiff**2
# bnvar = 1/(n-1)*(bndiff2).sum(0, keepdim=True) # note: Bessel's correction (dividing by n-1, not n)
# bnvar_inv = (bnvar + 1e-5)**-0.5
# bnraw = bndiff * bnvar_inv
# hpreact = bngain * bnraw + bnbias

# now:
# hpreact_fast = bngain * (hprebn - hprebn.mean(0, keepdim=True)) / torch.sqrt(hprebn.var(0, keepdim=True, unbiased=True) + 1e-5) + bnbias
# print('max diff:', (hpreact_fast - hpreact).abs().max())
hprebn          | exact: False | approximate: True  | maxdiff: 9.313225746154785e-10
# dhprebn.shape, bngain.shape, bnvar_inv.shape, dbnraw.shape, dbnraw.sum(0).shape

Training with the manual backward pass

12297
      0/ 200000: 3.7948
  10000/ 200000: 2.1612
  20000/ 200000: 2.3483
  30000/ 200000: 2.4187
  40000/ 200000: 1.9691
  50000/ 200000: 2.3682
  60000/ 200000: 2.3674
  70000/ 200000: 2.0210
  80000/ 200000: 2.3817
  90000/ 200000: 2.0875
 100000/ 200000: 2.0010
 110000/ 200000: 2.4035
 120000/ 200000: 2.0293
---------------------------------------------------------------------------
KeyboardInterrupt                         Traceback (most recent call last)
Cell In[82], line 82
     78     dhprebn = bngain*bnvar_inv/n * (n*dhpreact - dhpreact.sum(0) - n/(n-1)*bnraw*(dhpreact*bnraw).sum(0))
     79     # 1st layer
     80     dembcat = dhprebn @ W1.T
     81     dW1 = embcat.T @ dhprebn
---> 82     db1 = dhprebn.sum(0)
     83     # embedding
     84     demb = dembcat.view(emb.shape)
     85     dC = torch.zeros_like(C)

KeyboardInterrupt: 
# # useful for checking your gradients
# for p,g in zip(parameters, grads):
#   cmp(str(tuple(p.shape)), g, p)
train 2.079331398010254
val 2.115342855453491
# Andrej achieved:
# train 2.0718822479248047
# val 2.1162495613098145
# I achieved:
# train 2.079331398010254
# val 2.115342855453491
carmah.
amelle.
khi.
mri.
reity.
salayson.
rahnen.
delynn.
jarqui.
nellara.
chaiivon.
leigh.
ham.
joce.
quint.
sulio.
alianni.
waverogiearynix.
kael.
dura.