# nn-zero-to-hero Lecture 4: Activations, Gradients, BatchNorm **Source:** Andrej Karpathy, nn-zero-to-hero lecture series **Notebook:** lectures/makemore/makemore_part3_bn.ipynb **URL:** https://github.com/karpathy/nn-zero-to-hero **Raw:** https://raw.githubusercontent.com/karpathy/nn-zero-to-hero/master/lectures/makemore/makemore_part3_bn.ipynb **Lecture description:** "We dive into some of the internals of MLPs with multiple layers and scrutinize the statistics of the forward pass activations, backward pass gradients, and some of the typical diagnostic tools and visualizations you'd want to use to understand the health of your deep network." **Fetched-via:** curl of the raw .ipynb from GitHub, 2026-08-15, cells rendered to markdown with outputs dropped. (CLAUDE agent) **Fetch-status:** full notebook source. The skill-authored diagnostic reading notes come first, then the complete notebook. (CLAUDE agent) --- # Skill-authored reading notes The "Healthy / Bad" annotations below are written for this skill, not quoted from Karpathy. The notebook code they refer to follows in the next section. ## Incremental improvements documented (from notebook markdown) ``` original: train 2.1245 val 2.1682 fix softmax wrong: train 2.07 val 2.13 (overconfident init) fix tanh saturated: train 2.0356 val 2.1027 (init scale too large) use kaiming init: train 2.0377 val 2.1070 (semi-principled) add batch norm: train 2.0668 val 2.1048 (stable across random seeds) ``` Each row = one targeted fix. The ordering demonstrates the hierarchy: data/loss first, then init, then architecture. --- ## Activation saturation check (tanh) ```python for i, layer in enumerate(layers[:-1]): if isinstance(layer, Tanh): t = layer.out print('layer %d (%10s): mean %+.2f, std %.2f, saturated: %.2f%%' % (i, layer.__class__.__name__, t.mean(), t.std(), (t.abs() > 0.97).float().mean() * 100)) hy, hx = torch.histogram(t, density=True) plt.plot(hx[:-1].detach(), hy.detach()) # Healthy: distributions roughly Gaussian, saturation <5%. # Bad: bimodal at +/-1 = too saturated (weights too large at init, or missing BN). # Bad: all near 0 = dead layer (weights too small or gain 0). ``` --- ## Gradient distribution check (per-layer) ```python for i, layer in enumerate(layers[:-1]): if isinstance(layer, Tanh): t = layer.out.grad # requires retain_grad() in training loop print('layer %d (%10s): mean %+f, std %e' % (i, layer.__class__.__name__, t.mean(), t.std())) hy, hx = torch.histogram(t, density=True) plt.plot(hx[:-1].detach(), hy.detach()) # Healthy: similar gradient std across layers (no vanishing/exploding gradient). # Bad: gradient std shrinks toward earlier layers = vanishing gradient. # Bad: gradient std explodes = need BN, gradient clipping, or better init. ``` --- ## Grad:data ratio check (weight matrices) ```python for i, p in enumerate(parameters): t = p.grad if p.ndim == 2: print('weight %10s | mean %+f | std %e | grad:data ratio %e' % (tuple(p.shape), t.mean(), t.std(), t.std() / p.std())) hy, hx = torch.histogram(t, density=True) plt.plot(hx[:-1].detach(), hy.detach()) # grad:data ratio ~ 1e-3 is healthy. # Much higher: gradients dominate weights, learning rate too large. # Much lower: weights barely moving, potentially dead layer. ``` --- ## Update-to-data ratio tracker (training loop) ```python ud = [] # Inside training loop: for p in parameters: p.data += -lr * p.grad with torch.no_grad(): ud.append([((lr * p.grad).std() / p.data.std()).log10().item() for p in parameters]) # After training, plot: plt.figure(figsize=(20, 4)) legends = [] for i, p in enumerate(parameters): if p.ndim == 2: plt.plot([ud[j][i] for j in range(len(ud))]) legends.append('param %d' % i) plt.plot([0, len(ud)], [-3, -3], 'k') # target ~1e-3 plt.legend(legends) # Each line should stay near -3. # Rising above -3: LR too large, may diverge. # Sinking below -3: LR too small, near-zero updates. # Diverging between layers: need better initialization or BN. ``` --- ## Key pedagogical insight from the notebook The notebook demonstrates by construction (not just assertion) that: 1. Saturated tanh at init → slow learning (gradient vanishes through tanh) 2. Kaiming init → ~same scale activations throughout depth 3. BatchNorm → robust to poor init; normalization forces healthy activation stats The incremental improvement log (above) makes this concrete: each targeted fix yields measurable improvement. This is the same pattern as the recipe blog post but with code and measured results. --- # Full notebook source: makemore_part3_bn.ipynb --- # makemore: part 3 ```python import torch import torch.nn.functional as F import matplotlib.pyplot as plt # for making figures %matplotlib inline ``` ```python # read in all the words words = open('names.txt', 'r').read().splitlines() words[:8] ``` ```python len(words) ``` ```python # build the vocabulary of characters and mappings to/from integers chars = sorted(list(set(''.join(words)))) stoi = {s:i+1 for i,s in enumerate(chars)} stoi['.'] = 0 itos = {i:s for s,i in stoi.items()} vocab_size = len(itos) print(itos) print(vocab_size) ``` ```python # build the dataset block_size = 3 # context length: how many characters do we take to predict the next one? def build_dataset(words): X, Y = [], [] for w in words: context = [0] * block_size for ch in w + '.': ix = stoi[ch] X.append(context) Y.append(ix) context = context[1:] + [ix] # crop and append X = torch.tensor(X) Y = torch.tensor(Y) print(X.shape, Y.shape) return X, Y import random random.seed(42) random.shuffle(words) n1 = int(0.8*len(words)) n2 = int(0.9*len(words)) Xtr, Ytr = build_dataset(words[:n1]) # 80% Xdev, Ydev = build_dataset(words[n1:n2]) # 10% Xte, Yte = build_dataset(words[n2:]) # 10% ``` ```python # MLP revisited n_embd = 10 # the dimensionality of the character embedding vectors n_hidden = 200 # the number of neurons in the hidden layer of the MLP g = torch.Generator().manual_seed(2147483647) # for reproducibility C = torch.randn((vocab_size, n_embd), generator=g) W1 = torch.randn((n_embd * block_size, n_hidden), generator=g) * (5/3)/((n_embd * block_size)**0.5) #* 0.2 #b1 = torch.randn(n_hidden, generator=g) * 0.01 W2 = torch.randn((n_hidden, vocab_size), generator=g) * 0.01 b2 = torch.randn(vocab_size, generator=g) * 0 # BatchNorm parameters bngain = torch.ones((1, n_hidden)) bnbias = torch.zeros((1, n_hidden)) bnmean_running = torch.zeros((1, n_hidden)) bnstd_running = torch.ones((1, n_hidden)) parameters = [C, W1, W2, b2, bngain, bnbias] print(sum(p.nelement() for p in parameters)) # number of parameters in total for p in parameters: p.requires_grad = True ``` ```python # same optimization as last time max_steps = 200000 batch_size = 32 lossi = [] for i in range(max_steps): # minibatch construct ix = torch.randint(0, Xtr.shape[0], (batch_size,), generator=g) Xb, Yb = Xtr[ix], Ytr[ix] # batch X,Y # 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 # BatchNorm layer # ------------------------------------------------------------- bnmeani = hpreact.mean(0, keepdim=True) bnstdi = hpreact.std(0, keepdim=True) hpreact = bngain * (hpreact - bnmeani) / bnstdi + bnbias with torch.no_grad(): 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() # update lr = 0.1 if i < 100000 else 0.01 # step learning rate decay for p in parameters: p.data += -lr * p.grad # track stats if i % 10000 == 0: # print every once in a while print(f'{i:7d}/{max_steps:7d}: {loss.item():.4f}') lossi.append(loss.log10().item()) ``` ```python plt.plot(lossi) ``` ```python # calibrate the batch norm at the end of training with torch.no_grad(): # pass the training set through emb = C[Xtr] embcat = emb.view(emb.shape[0], -1) hpreact = embcat @ W1 # + b1 # measure the mean/std over the entire training set bnmean = hpreact.mean(0, keepdim=True) bnstd = hpreact.std(0, keepdim=True) ``` ```python @torch.no_grad() # this decorator disables gradient tracking def split_loss(split): x,y = { 'train': (Xtr, Ytr), 'val': (Xdev, Ydev), 'test': (Xte, Yte), }[split] emb = C[x] # (N, block_size, n_embd) embcat = emb.view(emb.shape[0], -1) # concat into (N, block_size * n_embd) hpreact = embcat @ W1 # + b1 #hpreact = bngain * (hpreact - hpreact.mean(0, keepdim=True)) / hpreact.std(0, keepdim=True) + bnbias hpreact = bngain * (hpreact - bnmean_running) / bnstd_running + bnbias h = torch.tanh(hpreact) # (N, n_hidden) logits = h @ W2 + b2 # (N, vocab_size) loss = F.cross_entropy(logits, y) print(split, loss.item()) split_loss('train') split_loss('val') ``` ## loss log ### original: train 2.1245384216308594 val 2.168196439743042 ### fix softmax confidently wrong: train 2.07 val 2.13 ### fix tanh layer too saturated at init: train 2.0355966091156006 val 2.1026785373687744 ### use semi-principled "kaiming init" instead of hacky init: train 2.0376641750335693 val 2.106989622116089 ### add batch norm layer train 2.0668270587921143 val 2.104844808578491 ```python # SUMMARY + PYTORCHIFYING ----------- ``` ```python # Let's train a deeper network # The classes we create here are the same API as nn.Module in PyTorch class Linear: def __init__(self, fan_in, fan_out, bias=True): self.weight = torch.randn((fan_in, fan_out), generator=g) / fan_in**0.5 self.bias = torch.zeros(fan_out) if bias else None def __call__(self, x): self.out = x @ self.weight if self.bias is not None: self.out += self.bias return self.out def parameters(self): return [self.weight] + ([] if self.bias is None else [self.bias]) class BatchNorm1d: def __init__(self, dim, eps=1e-5, momentum=0.1): self.eps = eps self.momentum = momentum self.training = True # parameters (trained with backprop) self.gamma = torch.ones(dim) self.beta = torch.zeros(dim) # buffers (trained with a running 'momentum update') self.running_mean = torch.zeros(dim) self.running_var = torch.ones(dim) def __call__(self, x): # calculate the forward pass if self.training: xmean = x.mean(0, keepdim=True) # batch mean xvar = x.var(0, keepdim=True) # batch variance else: xmean = self.running_mean xvar = self.running_var xhat = (x - xmean) / torch.sqrt(xvar + self.eps) # normalize to unit variance self.out = self.gamma * xhat + self.beta # update the buffers if self.training: with torch.no_grad(): self.running_mean = (1 - self.momentum) * self.running_mean + self.momentum * xmean self.running_var = (1 - self.momentum) * self.running_var + self.momentum * xvar return self.out def parameters(self): return [self.gamma, self.beta] class Tanh: def __call__(self, x): self.out = torch.tanh(x) return self.out def parameters(self): return [] n_embd = 10 # the dimensionality of the character embedding vectors n_hidden = 100 # the number of neurons in the hidden layer of the MLP g = torch.Generator().manual_seed(2147483647) # for reproducibility C = torch.randn((vocab_size, n_embd), generator=g) layers = [ Linear(n_embd * block_size, n_hidden, bias=False), BatchNorm1d(n_hidden), Tanh(), Linear( n_hidden, n_hidden, bias=False), BatchNorm1d(n_hidden), Tanh(), Linear( n_hidden, n_hidden, bias=False), BatchNorm1d(n_hidden), Tanh(), Linear( n_hidden, n_hidden, bias=False), BatchNorm1d(n_hidden), Tanh(), Linear( n_hidden, n_hidden, bias=False), BatchNorm1d(n_hidden), Tanh(), Linear( n_hidden, vocab_size, bias=False), BatchNorm1d(vocab_size), ] # layers = [ # Linear(n_embd * block_size, n_hidden), Tanh(), # Linear( n_hidden, n_hidden), Tanh(), # Linear( n_hidden, n_hidden), Tanh(), # Linear( n_hidden, n_hidden), Tanh(), # Linear( n_hidden, n_hidden), Tanh(), # Linear( n_hidden, vocab_size), # ] with torch.no_grad(): # last layer: make less confident layers[-1].gamma *= 0.1 #layers[-1].weight *= 0.1 # all other layers: apply gain for layer in layers[:-1]: if isinstance(layer, Linear): layer.weight *= 1.0 #5/3 parameters = [C] + [p for layer in layers for p in layer.parameters()] print(sum(p.nelement() for p in parameters)) # number of parameters in total for p in parameters: p.requires_grad = True ``` ```python # same optimization as last time max_steps = 200000 batch_size = 32 lossi = [] ud = [] for i in range(max_steps): # minibatch construct ix = torch.randint(0, Xtr.shape[0], (batch_size,), generator=g) Xb, Yb = Xtr[ix], Ytr[ix] # batch X,Y # forward pass emb = C[Xb] # embed the characters into vectors x = emb.view(emb.shape[0], -1) # concatenate the vectors for layer in layers: x = layer(x) loss = F.cross_entropy(x, Yb) # loss function # backward pass for layer in layers: layer.out.retain_grad() # AFTER_DEBUG: would take out retain_graph for p in parameters: p.grad = None loss.backward() # update lr = 0.1 if i < 150000 else 0.01 # step learning rate decay for p in parameters: p.data += -lr * p.grad # track stats if i % 10000 == 0: # print every once in a while print(f'{i:7d}/{max_steps:7d}: {loss.item():.4f}') lossi.append(loss.log10().item()) with torch.no_grad(): ud.append([((lr*p.grad).std() / p.data.std()).log10().item() for p in parameters]) if i >= 1000: break # AFTER_DEBUG: would take out obviously to run full optimization ``` ```python # visualize histograms plt.figure(figsize=(20, 4)) # width and height of the plot legends = [] for i, layer in enumerate(layers[:-1]): # note: exclude the output layer if isinstance(layer, Tanh): t = layer.out print('layer %d (%10s): mean %+.2f, std %.2f, saturated: %.2f%%' % (i, layer.__class__.__name__, t.mean(), t.std(), (t.abs() > 0.97).float().mean()*100)) hy, hx = torch.histogram(t, density=True) plt.plot(hx[:-1].detach(), hy.detach()) legends.append(f'layer {i} ({layer.__class__.__name__}') plt.legend(legends); plt.title('activation distribution') ``` ```python # visualize histograms plt.figure(figsize=(20, 4)) # width and height of the plot legends = [] for i, layer in enumerate(layers[:-1]): # note: exclude the output layer if isinstance(layer, Tanh): t = layer.out.grad print('layer %d (%10s): mean %+f, std %e' % (i, layer.__class__.__name__, t.mean(), t.std())) hy, hx = torch.histogram(t, density=True) plt.plot(hx[:-1].detach(), hy.detach()) legends.append(f'layer {i} ({layer.__class__.__name__}') plt.legend(legends); plt.title('gradient distribution') ``` ```python # visualize histograms plt.figure(figsize=(20, 4)) # width and height of the plot legends = [] for i,p in enumerate(parameters): t = p.grad if p.ndim == 2: print('weight %10s | mean %+f | std %e | grad:data ratio %e' % (tuple(p.shape), t.mean(), t.std(), t.std() / p.std())) hy, hx = torch.histogram(t, density=True) plt.plot(hx[:-1].detach(), hy.detach()) legends.append(f'{i} {tuple(p.shape)}') plt.legend(legends) plt.title('weights gradient distribution'); ``` ```python plt.figure(figsize=(20, 4)) legends = [] for i,p in enumerate(parameters): if p.ndim == 2: plt.plot([ud[j][i] for j in range(len(ud))]) legends.append('param %d' % i) plt.plot([0, len(ud)], [-3, -3], 'k') # these ratios should be ~1e-3, indicate on plot plt.legend(legends); ``` ```python @torch.no_grad() # this decorator disables gradient tracking def split_loss(split): x,y = { 'train': (Xtr, Ytr), 'val': (Xdev, Ydev), 'test': (Xte, Yte), }[split] emb = C[x] # (N, block_size, n_embd) x = emb.view(emb.shape[0], -1) # concat into (N, block_size * n_embd) for layer in layers: x = layer(x) loss = F.cross_entropy(x, y) print(split, loss.item()) # put layers into eval mode for layer in layers: layer.training = False split_loss('train') split_loss('val') ``` ```python # sample from the model g = torch.Generator().manual_seed(2147483647 + 10) for _ in range(20): out = [] context = [0] * block_size # initialize with all ... while True: # forward pass the neural net emb = C[torch.tensor([context])] # (1,block_size,n_embd) x = emb.view(emb.shape[0], -1) # concatenate the vectors for layer in layers: x = layer(x) logits = x probs = F.softmax(logits, dim=1) # sample from the distribution ix = torch.multinomial(probs, num_samples=1, generator=g).item() # shift the context window and track the samples context = context[1:] + [ix] out.append(ix) # if we sample the special '.' token, break if ix == 0: break print(''.join(itos[i] for i in out)) # decode and print the generated word ``` ```python # DONE; BONUS content below, not covered in video ``` ```python # BatchNorm forward pass as a widget from ipywidgets import interact, interactive, fixed, interact_manual import ipywidgets as widgets import scipy.stats as stats import numpy as np def normshow(x0): g = torch.Generator().manual_seed(2147483647+1) x = torch.randn(5, generator=g) * 5 x[0] = x0 # override the 0th example with the slider mu = x.mean() sig = x.std() y = (x - mu)/sig plt.figure(figsize=(10, 5)) # plot 0 plt.plot([-6,6], [0,0], 'k') # plot the mean and std xx = np.linspace(-6, 6, 100) plt.plot(xx, stats.norm.pdf(xx, mu, sig), 'b') xx = np.linspace(-6, 6, 100) plt.plot(xx, stats.norm.pdf(xx, 0, 1), 'r') # plot little lines connecting input and output for i in range(len(x)): plt.plot([x[i],y[i]], [1, 0], 'k', alpha=0.2) # plot the input and output values plt.scatter(x.data, torch.ones_like(x).data, c='b', s=100) plt.scatter(y.data, torch.zeros_like(y).data, c='r', s=100) plt.xlim(-6, 6) # title plt.title('input mu %.2f std %.2f' % (mu, sig)) interact(normshow, x0=(-30,30,0.5)); ``` ```python # Linear: activation statistics of forward and backward pass g = torch.Generator().manual_seed(2147483647) a = torch.randn((1000,1), requires_grad=True, generator=g) # a.grad = b.T @ c.grad b = torch.randn((1000,1000), requires_grad=True, generator=g) # b.grad = c.grad @ a.T c = b @ a loss = torch.randn(1000, generator=g) @ c a.retain_grad() b.retain_grad() c.retain_grad() loss.backward() print('a std:', a.std().item()) print('b std:', b.std().item()) print('c std:', c.std().item()) print('-----') print('c grad std:', c.grad.std().item()) print('a grad std:', a.grad.std().item()) print('b grad std:', b.grad.std().item()) ``` ```python # Linear + BatchNorm: activation statistics of forward and backward pass g = torch.Generator().manual_seed(2147483647) n = 1000 # linear layer --- inp = torch.randn(n, requires_grad=True, generator=g) w = torch.randn((n, n), requires_grad=True, generator=g) # / n**0.5 x = w @ inp # bn layer --- xmean = x.mean() xvar = x.var() out = (x - xmean) / torch.sqrt(xvar + 1e-5) # ---- loss = out @ torch.randn(n, generator=g) inp.retain_grad() x.retain_grad() w.retain_grad() out.retain_grad() loss.backward() print('inp std: ', inp.std().item()) print('w std: ', w.std().item()) print('x std: ', x.std().item()) print('out std: ', out.std().item()) print('------') print('out grad std: ', out.grad.std().item()) print('x grad std: ', x.grad.std().item()) print('w grad std: ', w.grad.std().item()) print('inp grad std: ', inp.grad.std().item()) ```