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.

Activations and normalization

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.

Understanding activation statistics

# we want to move on to larger neural networks like recurrent neural networks and their variations like GRU, lstm, etc
# but before that we want to have a good understanding of activations in neural net during training
# and specially of the gradients that are flowing backwards, how they behave, and what they look like
# this is imp to understand the history of development of these architectures
# while RNN are expressive, they are a universal approximator and in principle can implement all algorithms
# we'll see that RNN are not very easily optimizable with the first order gradient based techniques that we use all the time
# to understand why RNN are not easily optimizable, we need to understand the activations and gradients and how they behave during training
# and we'll see that a lot of variants since RNN have tried to improve this situation
# starting code for this lecture is the same as before but minor tweaks that you'll understand easily, and with help of comments
['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])
11897
      0/ 200000: 27.8817
   1000/ 200000: 4.1661
   2000/ 200000: 3.0921
   3000/ 200000: 2.9381
   4000/ 200000: 2.2535
   5000/ 200000: 2.3730
   6000/ 200000: 2.3974
   7000/ 200000: 2.8007
   8000/ 200000: 2.4276
   9000/ 200000: 2.5784
  10000/ 200000: 2.9571
  11000/ 200000: 2.0548
  12000/ 200000: 2.3778
  13000/ 200000: 2.3974
  14000/ 200000: 2.8170
  15000/ 200000: 2.2712
  16000/ 200000: 2.5917
  17000/ 200000: 2.6650
  18000/ 200000: 2.4507
  19000/ 200000: 2.4330
  20000/ 200000: 2.5722
  21000/ 200000: 2.6016
  22000/ 200000: 2.1298
  23000/ 200000: 2.1793
  24000/ 200000: 3.0445
  25000/ 200000: 2.4252
  26000/ 200000: 2.5052
  27000/ 200000: 2.6437
  28000/ 200000: 2.2556
  29000/ 200000: 3.1923
  30000/ 200000: 2.7646
  31000/ 200000: 2.6741
  32000/ 200000: 2.5378
  33000/ 200000: 2.5036
  34000/ 200000: 2.1476
  35000/ 200000: 2.4872
  36000/ 200000: 2.1147
  37000/ 200000: 2.3196
  38000/ 200000: 2.9486
  39000/ 200000: 2.0292
  40000/ 200000: 2.0305
  41000/ 200000: 2.4910
  42000/ 200000: 2.7686
  43000/ 200000: 2.5346
  44000/ 200000: 1.9142
  45000/ 200000: 1.9151
  46000/ 200000: 2.4361
  47000/ 200000: 2.0344
  48000/ 200000: 2.6735
  49000/ 200000: 2.1647
  50000/ 200000: 2.5794
  51000/ 200000: 2.9069
  52000/ 200000: 2.4059
  53000/ 200000: 2.4307
  54000/ 200000: 2.1391
  55000/ 200000: 2.0361
  56000/ 200000: 2.0808
  57000/ 200000: 1.7228
  58000/ 200000: 1.7367
  59000/ 200000: 2.3184
  60000/ 200000: 2.3242
  61000/ 200000: 2.1974
  62000/ 200000: 2.6087
  63000/ 200000: 2.4233
  64000/ 200000: 2.2013
  65000/ 200000: 2.0786
  66000/ 200000: 2.6614
  67000/ 200000: 2.6344
  68000/ 200000: 1.9336
  69000/ 200000: 2.2588
  70000/ 200000: 2.1174
  71000/ 200000: 2.4858
  72000/ 200000: 2.4512
  73000/ 200000: 2.4317
  74000/ 200000: 1.8438
  75000/ 200000: 2.7360
  76000/ 200000: 1.9129
  77000/ 200000: 2.4255
  78000/ 200000: 2.4489
  79000/ 200000: 2.2000
  80000/ 200000: 2.2993
  81000/ 200000: 2.3183
  82000/ 200000: 2.3165
  83000/ 200000: 1.8720
  84000/ 200000: 2.6831
  85000/ 200000: 2.6265
  86000/ 200000: 2.4700
  87000/ 200000: 2.2398
  88000/ 200000: 2.5135
  89000/ 200000: 2.3752
  90000/ 200000: 2.2481
  91000/ 200000: 2.0835
  92000/ 200000: 2.6384
  93000/ 200000: 1.9178
  94000/ 200000: 1.9773
  95000/ 200000: 2.2083
  96000/ 200000: 2.1389
  97000/ 200000: 2.3097
  98000/ 200000: 1.9052
  99000/ 200000: 2.0819
 100000/ 200000: 2.0146
 101000/ 200000: 1.8770
 102000/ 200000: 2.2421
 103000/ 200000: 2.1059
 104000/ 200000: 2.2212
 105000/ 200000: 2.1796
 106000/ 200000: 2.3958
 107000/ 200000: 2.2501
 108000/ 200000: 2.2253
 109000/ 200000: 2.3500
 110000/ 200000: 2.4534
 111000/ 200000: 2.2884
 112000/ 200000: 1.8768
 113000/ 200000: 2.3609
 114000/ 200000: 2.1293
 115000/ 200000: 2.2429
 116000/ 200000: 2.2294
 117000/ 200000: 2.4280
 118000/ 200000: 2.2524
 119000/ 200000: 2.3136
 120000/ 200000: 1.9164
 121000/ 200000: 2.3675
 122000/ 200000: 2.2222
 123000/ 200000: 2.4959
 124000/ 200000: 2.8668
 125000/ 200000: 1.9559
 126000/ 200000: 2.2162
 127000/ 200000: 2.1895
 128000/ 200000: 2.5736
 129000/ 200000: 2.1753
 130000/ 200000: 2.3719
 131000/ 200000: 1.8927
 132000/ 200000: 1.9425
 133000/ 200000: 2.3153
 134000/ 200000: 2.0467
 135000/ 200000: 2.0507
 136000/ 200000: 1.9243
 137000/ 200000: 1.9577
 138000/ 200000: 2.1316
 139000/ 200000: 1.8914
 140000/ 200000: 2.2377
 141000/ 200000: 1.7482
 142000/ 200000: 2.0938
 143000/ 200000: 2.4556
 144000/ 200000: 2.0439
 145000/ 200000: 2.1131
 146000/ 200000: 2.1402
 147000/ 200000: 2.2063
 148000/ 200000: 2.4627
 149000/ 200000: 2.3908
 150000/ 200000: 2.1441
 151000/ 200000: 2.3513
 152000/ 200000: 1.9725
 153000/ 200000: 2.2721
 154000/ 200000: 2.3124
 155000/ 200000: 2.0890
 156000/ 200000: 2.0114
 157000/ 200000: 2.1071
 158000/ 200000: 1.9900
 159000/ 200000: 2.2106
 160000/ 200000: 2.2661
 161000/ 200000: 2.0200
 162000/ 200000: 1.9794
 163000/ 200000: 2.0711
 164000/ 200000: 2.1216
 165000/ 200000: 2.3295
 166000/ 200000: 2.1930
 167000/ 200000: 2.3925
 168000/ 200000: 1.9545
 169000/ 200000: 2.2910
 170000/ 200000: 1.7855
 171000/ 200000: 2.2078
 172000/ 200000: 2.2121
 173000/ 200000: 2.2136
 174000/ 200000: 2.4515
 175000/ 200000: 2.0452
 176000/ 200000: 2.0188
 177000/ 200000: 2.1216
 178000/ 200000: 1.8341
 179000/ 200000: 2.0375
 180000/ 200000: 2.1126
 181000/ 200000: 2.2063
 182000/ 200000: 2.2261
 183000/ 200000: 2.2427
 184000/ 200000: 2.3953
 185000/ 200000: 2.2676
 186000/ 200000: 1.7918
 187000/ 200000: 1.8241
 188000/ 200000: 2.1250
 189000/ 200000: 2.4299
 190000/ 200000: 1.8004
 191000/ 200000: 2.1577
 192000/ 200000: 2.3515
 193000/ 200000: 2.1037
 194000/ 200000: 2.0033
 195000/ 200000: 2.2219
 196000/ 200000: 2.1530
 197000/ 200000: 2.1457
 198000/ 200000: 2.0172
 199000/ 200000: 1.7894
# First thing we want to scrutinize(examine carefully) is the initialization
# we know that our network is very improperly configured at initialization
# at 0th iteration, loss = 27, and at 1000th iteration, it rapidly comes down at 4
# while training neural nets, we will always have a rough idea of what loss to expect at initialization which depends on loss function and problem setup
# what we'd like at initialization instead:
# so there's 27 characters that could come next right? at initialization we have no reason to believe any characters to be much more likely than others
# so initially, the probability distribution is a uniform distribution that assigns about equal probability to all 27 characters
# so for any char: prob is roughly 1/27.0
# and loss is the negative log probability which we can compute directly like this
tensor(3.2958)
# so, initially our expected loss is 3.29, which is way lower than 27
# currently, at initialization: some characters are very confident and some are not confident due to randomly assigning probabilities initially
# so, the network is very confidently wrong which increases the loss by a lot
(tensor([0.2500, 0.2500, 0.2500, 0.2500]), tensor(1.3863))
(tensor([0.0066, 0.0066, 0.9802, 0.0066]), tensor(0.0200))
(tensor([0.0066, 0.9802, 0.0066, 0.0066]), tensor(5.0200))
# for the below random initialization case, since the logits that come out are near 0, the loss is okay
# but suppose its x10, you'll see that because these are more extreme values, its unlikely to guess correctly, so you're confidently wrong and thus recording very high loss
# and if the logits are very extreme, you might get insane losses like infinity
(tensor([-1.7050, 1.2582, 0.0039, 1.4227]), tensor([0.0205, 0.3975, 0.1134, 0.4686]), tensor(2.1769))
(tensor([ 35.4741, -104.2379, 9.5114, -151.8761]), tensor([1.0000e+00, 0.0000e+00, 5.3033e-12, 0.0000e+00]), tensor(25.9627))

Initial predictions and confidence

SUMMARY: RANDOM INITIALIZATION LIKE THIS IS NOT GOOD, AND WE WANT THE LOGITS TO BE ROUGHLY ZERO WHEN THE NETWORK IS INITIALIZED
iNFACT, THE LOGITS DON'T HAVE TO BE 0, THEY JUST HAVE TO BE EQUAL
(tensor([1., 1., 1., 1.]), tensor([0.2500, 0.2500, 0.2500, 0.2500]), tensor(1.3863))
[<matplotlib.lines.Line2D at 0x72ff55056a50>]
<Figure size 640x480 with 1 Axes>
train 2.1270017623901367
val 2.169905662536621
carlah.
amille.
khi.
mili.
taty.
skandeleja.
hutafaresara.
kaeli.
nellara.
chaiir.
kaleigh.
ham.
jorn.
quint.
salin.
alianni.
waztrogiearynix.
kael.
dura.
mer.
# the output is better, but still not amazing, and still not fully name like but way better than bigram model
# so, where do things currently go wrong during initialization in our neural net?
      0/ 200000: 27.8817
tensor([-31.2260, 15.0341, 3.7797, -4.1423, 5.1893, 11.4668, 6.5744, 13.6292, -11.6972, -7.1791, 2.2053, 38.5218, -0.1899, -25.6748, 16.8003, 7.3650, 8.0879, -5.4867, -11.5406, -14.5259, 9.7959, 20.9665, -5.7736, -4.6396, 25.5794, 7.2416, 2.4288], grad_fn=<SelectBackward0>)
# how to make sure that logits coming out of neural net can be made much closer to zero?
# logits = h @ W2 + b2 #output layer
# we're initializing b2 as random values
# but now, since we want the logits to be roughly zero, we don't want to add any bias of random numbers to logits
# so at initialization, set b2 to 0, just multiply it by 0
# and if we want logits to be very small, we can multiply W2 to make it smaller, eg. multiply by 0.1 or 0.01 accordingly to scale down all elements

# MLP revisited
# ............................
# W2 = torch.randn((n_hidden, vocab_size), generator = g) * 0.1
# b2 = torch.randn(vocab_size, generator = g) * 0
# ........................
11897
      0/ 200000: 3.3221
# WAAAAU: look that we are already much much closer to our expected loss at first iteration itself
# what we roughly want is 3.29 from -torch.tensor(1/27.0).log(). This is our reference
# currently when multiplying S2 by 0.01, we're getting a loss of 3.32
# but can we set W2 to multiply by 0 at initialization, it will ensure that we get exactly 3.29?
# but we don't want to set the weights of a neural net to exactly 0 at initialization because we are nervous of something
# so we set it to very small num instead of 0
# what is that something?
# so we're not setting W2 to be exactly 0, but near 0: i.e. there is some entropy/randomness/uncertainity
# and this entropy is used for symmetry breaking
tensor([-0.3136, 0.1383, 0.0184, -0.0445, 0.0317, 0.1146, 0.0738, 0.1302, -0.1349, -0.0651, 0.0348, 0.3965, -0.0048, -0.2584, 0.1568, 0.0671, 0.1079, -0.0677, -0.1155, -0.1388, 0.0844, 0.2292, -0.0689, -0.0466, 0.2477, 0.0804, 0.0268], grad_fn=<SelectBackward0>)
      0/ 200000: 3.3221
  10000/ 200000: 2.1900
  20000/ 200000: 2.4196
  30000/ 200000: 2.6067
  40000/ 200000: 2.0601
  50000/ 200000: 2.4988
  60000/ 200000: 2.3902
  70000/ 200000: 2.1344
  80000/ 200000: 2.3369
  90000/ 200000: 2.1299
 100000/ 200000: 1.8329
 110000/ 200000: 2.2053
 120000/ 200000: 1.8540
 130000/ 200000: 2.4566
 140000/ 200000: 2.1879
 150000/ 200000: 2.1118
 160000/ 200000: 1.8956
 170000/ 200000: 1.8645
 180000/ 200000: 2.0326
 190000/ 200000: 1.8417
[<matplotlib.lines.Line2D at 0x72ff548e0ec0>]
<Figure size 640x480 with 1 Axes>
# now, we see that the plot of the loss doesn't have a hockey stick appearance
# cuz in that previous case, for the very first few iterations of the loss, the optimization is just squashing down the logits, then rearranging the logits
# but now, we took away the easy part of the loss function of rapid shrinking of loss
# now, the plot shows the hard gains of the neural net
# and the loss has improved quite a bit too
train 2.069589138031006
val 2.1310746669769287
# previous loss log:
train 2.1270017623901367
val 2.169905662536621

# current loss log:
train 2.069589138031006
val 2.1310746669769287

Hidden activations and saturation

# the reason why our result is improved is because: we're spending more cycles optimizing the neural net actually,
# instead of spending the first several thousands iterations squashing down the weights
# Let's look at another problem now
11897
      0/ 200000: 3.3221
# though everything looks good on the level of loss, there's a deeper problem lurking inside this neural net and its initialization
# the logits are okay now, the problem now is with the values of h: the activations of the hidden states
# h = torch.tanh(hpreact)
tensor([[ 0.8100, -0.8997, -0.9993,  ..., -0.9097, -1.0000,  1.0000],
        [-1.0000, -0.9571, -0.7145,  ...,  0.4898,  0.9090,  0.9937],
        [ 0.9983, -0.3340,  1.0000,  ...,  0.9443,  0.9905,  1.0000],
        ...,
        [-1.0000,  0.9604, -0.1418,  ..., -0.1266,  1.0000,  1.0000],
        [-1.0000, -0.4385, -0.8882,  ..., -0.3316,  0.9995,  1.0000],
        [-1.0000,  0.9604, -0.1418,  ..., -0.1266,  1.0000,  1.0000]],
       grad_fn=<TanhBackward0>)
torch.Size([32, 200])
torch.Size([6400])
<Figure size 640x480 with 1 Axes>
<Figure size 640x480 with 1 Axes>
# if you're well versed in backpropagation, and have a sense of how gradients flow through this neural net, and looking at distributions of tanh activations
# you're sweating probably
# Plot a histogram of tanh activations (using ~50 bins) to inspect how active the neurons are.

# Observation:
# - Most tanh outputs are either very close to -1 or +1.
# - This means many neurons are SATURATED.
# - Instead of producing a wide range of values, they are stuck at the extremes.

# To understand why, inspect the PRE-ACTIVATIONS (values before tanh).
# - Their distribution is very wide (e.g. roughly -5 to +15).
# - Large positive inputs become ~+1 after tanh.
# - Large negative inputs become ~-1 after tanh.
# - tanh squashes everything into the range [-1, 1], so large inputs get "clipped" to the ends.

# Why is this bad?
# - Saturated neurons create the VANISHING GRADIENT problem.
# - During backpropagation, gradients must pass through every tanh neuron.
# - If many neurons are saturated, gradients become tiny or even zero.

# Recall tanh implementation from micrograd:
# t = tanh(x)

# During backward pass:
# x.grad += (1 - t**2) * out.grad

# Here:
# - out.grad = gradient arriving from later layers.
# - (1 - t**2) = local derivative of tanh.
# - Chain rule multiplies them together.

# Case 1: t ≈ +1
# derivative = 1 - (1)^2 = 0
# x.grad = 0 * out.grad = 0
# Gradient completely dies.

# Case 2: t ≈ -1
# derivative = 1 - (-1)^2 = 0
# Same result: gradient becomes zero.

# Intuition:
# - At the flat tails of tanh, changing the input barely changes the output.
# - If the output barely changes, the loss barely changes.
# - Therefore there is almost nothing useful to learn.
# - The weights feeding this neuron receive almost no gradient updates.

# Case 3: t = 0
# derivative = 1 - 0^2 = 1
# x.grad = out.grad
# Gradient passes through unchanged.

# Important intuition:
# - The closer tanh output is to 0, the better gradients flow.
# - The closer tanh output is to ±1, the more gradients are squashed.
# - tanh can ONLY reduce gradient magnitude because:
#       0 <= (1 - t**2) <= 1
# - It never amplifies gradients; it either preserves or shrinks them.

# Goal during initialization:
# - Keep activations centered around 0 instead of saturating at ±1.
# - This allows gradients to flow through the network efficiently and makes learning much easier.
# so our concern is, if all outputs h are in flat regions of -1 and 1, then the gradients flowing through the network will get destroyed at this layer during backprop
<matplotlib.image.AxesImage at 0x72ff546dc550>
<Figure size 2000x1000 with 1 Axes>
# we would be in deep trouble if for any one of the 200 neurons, the entire column was white: in that case, we have a dead neuron
# and it could be a tanh neuron where the initialization of the weights and biases could be such that no single example ever activates this tanh
# if all examples land in the tail, i.e. majority white, then this neuron will never learn: it is a dead neuron
# here thankfully, none of the neurons are completely white, and some examples do activate all neurons 
# some gradients will flow through the neuron and the neuron will learn, the neuron will do something
# this is true for other non-linearities like sigmoid and relu
# you can easily get a dead relu neuron : its weights and biases will never get a gradient, can happen during initialization and optimization
# eg. if learning rate is too high, sometimes a neuron gets too much gradient and they get knocked out of data manifold, 
# then no example ever activates that neuron forever, and its like a permanent brain damage in the mind of the network
# other non-linearities like leaky relu don't suffer from this issue as much cuz it doesn't have flat tails
# and you'll almost always get gradients
# elu might suffer from this issue cuz it has flat parts
# the h preactivation is too large and creating distributions too saturated on both sides of tanh
# and that's not good, cuz there's less training for these neurons and they update less frequently
# hpreact comes from C which is uniform gaussian(normal distribution) and W1 and b1
# hpreact is too far away from 0 which causes the issue, so like logits, we want hpreact to be closer to zero
# we can multiply b1 by small num, we want to get a little bit of variation/entropy in the original initialization of tanh neurons
# and in practice, it helps optimization a little bit
# and also multiply W1 by small num like 0.1 too
11897
# rerun the first batch only
      0/ 200000: 3.3069
<Figure size 640x480 with 1 Axes>
<Figure size 640x480 with 1 Axes>
# now the preactivations are from -1.5 to 1.5 instead of -15 to 15
<matplotlib.image.AxesImage at 0x72ff54309bd0>
<Figure size 2000x1000 with 1 Axes>
# currently, there are no neurons that saturated over 0.99
# slightly adjust, now multiplying W1 by 0.2 instead to get even distribution, also include some saturated neurons
11897
      0/ 200000: 3.3135
<Figure size 640x480 with 1 Axes>
<Figure size 640x480 with 1 Axes>
<matplotlib.image.AxesImage at 0x72ff4d7e0f50>
<Figure size 2000x1000 with 1 Axes>
# now, run the full optimization without the break at first iteration after resetting the parameters ofcourse
      0/ 200000: 3.3135
  10000/ 200000: 2.1648
  20000/ 200000: 2.3061
  30000/ 200000: 2.4541
  40000/ 200000: 1.9787
  50000/ 200000: 2.2930
  60000/ 200000: 2.4232
  70000/ 200000: 2.0680
  80000/ 200000: 2.3095
  90000/ 200000: 2.1207
 100000/ 200000: 1.8269
 110000/ 200000: 2.2045
 120000/ 200000: 1.9797
 130000/ 200000: 2.3946
 140000/ 200000: 2.1000
 150000/ 200000: 2.1948
 160000/ 200000: 1.8619
 170000/ 200000: 1.7809
 180000/ 200000: 1.9673
 190000/ 200000: 1.8295
train 2.0355966091156006
val 2.1026785373687744
# previous loss log:
train 2.1270017623901367
val 2.169905662536621

# previous loss log:
train 2.069589138031006
val 2.1310746669769287

# current loss log(better):
train 2.0355966091156006
val 2.1026785373687744

Scaling initialization

# why are our results better?
# our initialization is better, we're spending more time doing productive training instead of previous one where most gradients were set to 0
# this illustrates impact of initialization on performance by being aware of the internals of the neural nets and their activations and their gradients
# we're working with a very small network: single layer MLP, so since the network is so shallow, the optimization problem is very easy
# so though our initialization is terrible, the network eventually learned
# but this case doesn't occur in general, once we start working with deeper networks that have eg. 50 layers
# and if your initialization is bad, your network might not even train at all
# more complex the network, less forgiving it is to errors
# now, how did we come up with the magic numbers: 0.1, 0.2, 0.01 earlier to modify the initialization of weights and biases
# what to do incase of a large neural net with lots and lots of layers
# there are some principled ways to set those numbers
tensor(0.0035) tensor(1.0100)
tensor(-0.0125) tensor(3.1737)
<Figure size 2000x500 with 2 Axes>
# now, the correct answer is that we are supposed to divide by the square root of the fan_in
# fan_in: the no of input elements in W, in above case: 10, so divide by square root of 10
tensor(0.0047) tensor(0.9983)
tensor(4.0914e-05) tensor(1.0165)
<Figure size 2000x500 with 2 Axes>
# a number of papers have looked into best initialized neural networks, and in case of MLP, we can have deep networks with non-linearities in between
# we want to make sure that the activations are well-behaved and they don't expand to infinity or shrink all the way to 0
# so how do we initialize the weights so the activations take on reasonable values throughout the network?
# a paper Kaiming He that has studied this in detail and is often cited: Delving Deep into Rectifiers
# in the paper, they study the relu and prelu nonlinearity, but the analysis is very similar
# relu is a squashing function where all the negative numbers are simply clamped to zero, and positive numbers are pass through
# and because you're throwing away half the distribution, from their analysis of forward forward activations in the neural net
# you have to compensate for that with a gain
# i.e. they have to initialize their weights with a zero-mean Gaussian whose standard deviation is square root of 2 over fan_in
# "from paper
# This leads to a zero-mean Gaussian distribution whose standard deviation (std) is √2/nl. 
# This is our way of initialization.
# "
# they have to add a factor of 2 cuz of relu which discards half of the distribution

# this kind of initialization is also implemented in pytorch
# look at the torch.nn.init.kaiming_normal_(tensor, a=0, mode='fan_in', nonlinearity='leaky_relu', generator=None) part
# currently, this is the most common way of initializing neural networks
# it takes a few arguments
# 1. mode: would you like to normalize the activations or normalize the gradients to be always gaussian with 0 mean and 1 s.d.?
# it doesn't matter to much, so leave it at fan_in
# 2. passing the non_linearity that you're using, cuz depending on non-linearity, we need to calculate a slightly different gain
# if we pass no non-linearity, gain will be 1 as in above case, in case of relu, gain is square root of 2 which you can see in the top, because it was so in the paper
# incase of tanh, advised gain is 5/3
# why do we need a gain on top of the initialization?
# its cus tanh, relu are contractive transformation: the output distribution from the matrix multiplication will be squashed in some way
# relu squashes by clamping everything below 0 to 0
# tanh will take the tails and then squeeze them in. so to fight the squeeze, we need to boost the weights a little bit so that we renormalize everything back to unit(1) s.d
# that's why there's a little bit of gain
# about 7-9 years ago, everything was very fragile, the gradients, activations, histograms, scrutinizing non-linearities, etc specially if the neural net was deep
# modern innovations have made everything more stable and well behaved
# so its become less important to initialize these networks exactly right
# some modern innovations: 1. residual connections, 
# 2. use of number of normalization layers eg. batch normalization, layer normalization, group normalization
# 3. much better optimizers, not just stochastic gradient descent, but complex ones like RMS PROP and adam
# due to these innovations, it is less imp to precisely calibrate the initialization of the neural net
# in practice, what do we do then?
# just normalize the weights by the square root of the fan_in
# to be exactly accurate, std=(gain/fan_mode)**0.5
# https://docs.pytorch.org/docs/2.13/nn.init.html#torch-nn-init
# to set the s.d. of our weights, do the following:
tensor(1.0118)
tensor(0.1980)
# W1 = torch.randn((n_embd * block_size, n_hidden), generator = g) * 0.2 
# this ensured a s.d. of 0.2 gaussian when we sample our W1
# but we want to set the s.d. to square root of gain/fan_in
# from documentation: gain for tanh : 5/3
0.3042903097250923
# W1 = torch.randn((n_embd * block_size, n_hidden), generator = g) * 0.2 
# we're going to replace that 0.2 with something better now
# instead of 0.2, use:
# (5/3) / ((n_embd * block_size) ** 0.5)
# So,
# W1 = torch.randn((n_embd * block_size, n_hidden), generator = g) * (5/3) / ((n_embd * block_size) ** 0.5)
# so, this is how we want to initialize our neural net's weights for tanh non-linearity
# so that's what we'll do, and see what we get
0.3042903097250923
11897
      0/ 200000: 3.3179
  10000/ 200000: 2.1910
  20000/ 200000: 2.3270
  30000/ 200000: 2.5396
  40000/ 200000: 1.9468
  50000/ 200000: 2.3331
  60000/ 200000: 2.3852
  70000/ 200000: 2.1173
  80000/ 200000: 2.3159
  90000/ 200000: 2.2010
 100000/ 200000: 1.8591
 110000/ 200000: 2.0881
 120000/ 200000: 1.9389
 130000/ 200000: 2.3913
 140000/ 200000: 2.0949
 150000/ 200000: 2.1458
 160000/ 200000: 1.7824
 170000/ 200000: 1.7249
 180000/ 200000: 1.9752
 190000/ 200000: 1.8614
[<matplotlib.lines.Line2D at 0x72ff5616f0e0>]
<Figure size 640x480 with 1 Axes>
train 2.0376641750335693
val 2.106989622116089
# previous loss log:
train 2.1270017623901367
val 2.169905662536621

# previous loss log:
train 2.069589138031006
val 2.1310746669769287

# previous loss log
train 2.0355966091156006
val 2.1026785373687744

# current loss log
train 2.0376641750335693
val 2.106989622116089

Batch normalization

# so after initializing W1 like that, we still got roughly the same loss
# but the big deal is that we didn't have to use magic numbers we got looking from histograms and guess and checking
# we now use something semi-principled, and will scale us to much bigger networks
# the precise setup of the magic numbers during initializations is not so imp today due to some modern innovations
# one of those modern innovations is batch normalization
# we were talking about how hpreact shouldn't be too small, then the tanh isn't doing anything
# but we also don't want it to be too large, then the tanh becomes saturated and gradients don't flow
# what we want is for hpreact to be roughly Gaussian: so mean: 0 and S.D.:1 at initialization
# Insight from batch normalization paper:
# just take the hidden states and normalize them to be Gaussian
# just normalize the preactivation before it goes into the tanh bro
torch.Size([32, 200])
tensor([[1.9613, 1.3486, 2.6169, 2.9698, 2.7784, 2.1968, 2.1737, 3.2098, 1.8199, 1.9680, 1.8995, 1.9896, 1.0719, 2.4876, 1.9648, 1.7460, 2.8172, 2.7818, 2.6821, 2.4640, 2.6209, 3.3153, 1.6177, 2.3900, 2.4985, 1.7149, 2.0246, 2.5168, 2.8657, 2.4524, 1.6224, 1.8967, 2.7732, 1.6037, 1.5137, 2.2101, 2.4012, 2.3304, 2.0241, 1.8732, 1.4424, 2.1492, 1.6517, 1.5745, 1.8599, 1.9858, 1.9352, 2.8817, 1.7111, 2.4724, 2.0803, 2.0884, 1.9877, 2.0851, 1.7538, 2.4325, 1.8240, 2.4118, 2.6271, 1.5493, 1.7387, 2.2251, 1.7173, 1.3836, 2.9623, 1.6185, 1.7514, 2.0846, 2.2292, 2.5178, 2.4438, 1.4803, 1.5512, 2.4341, 1.5833, 1.7891, 2.3643, 2.0278, 1.3426, 2.3906, 1.8960, 2.4052, 1.6073, 2.6124, 2.5638, 2.3395, 1.9467, 2.4481, 2.1223, 2.2247, 2.2783, 2.2455, 1.9989, 2.1343, 2.4529, 1.8233, 2.5712, 1.5163, 2.1924, 2.2142, 2.3564, 2.3831, 2.9241, 2.2520, 1.9678, 1.5307, 2.4070, 1.6361, 2.2437, 1.4952, 2.4418, 2.5749, 1.8294, 2.2800, 2.1689, 2.5579, 1.6360, 2.2833, 2.2701, 2.9364, 1.6848, 2.6299, 1.6085, 2.5661, 2.6057, 1.8512, 1.8815, 1.6132, 1.9918, 1.3530, 2.6038, 2.5717, 1.9360, 2.5749, 1.6645, 1.8305, 1.9283, 2.4305, 1.7599, 1.9086, 2.3116, 2.5427, 1.9926, 1.7846, 2.6498, 2.1044, 1.8586, 1.4043, 1.9260, 1.5291, 2.7683, 2.6458, 2.0966, 2.1427, 1.5872, 1.9787, 2.3137, 2.0506, 1.8957, 2.4453, 1.9769, 1.6061, 2.1647, 2.6291, 2.0988, 1.2304, 3.1327, 1.9531, 2.4582, 1.9261, 2.1413, 2.2315, 2.2603, 1.4084, 3.1167, 2.3562, 2.9757, 2.1950, 2.9440, 1.7912, 1.2578, 3.0028, 2.5685, 1.4476, 2.6790, 2.2613, 2.6849, 1.4139, 2.0182, 2.3849, 1.7070, 1.8136, 1.6201, 1.5524, 1.8777, 1.6591, 2.2013, 1.4714, 1.7916, 1.7215]], grad_fn=<StdBackward0>)
torch.Size([1, 200])
torch.Size([1, 200])
# mean: average of neurons activation
# s.d: distance of every value away from the mean, its just the spread, that squared and averaged gives variance, sq root of variance gives s.d.
# then normalize/standardize the x values by subtracting the mean by them and dividing by s.d.
# hpreact = (hpreact - hpreact.mean(0, keepdim=True)) / hpreact.std(0, keepdim = True)
# so every single neuron and its firing rate will be exactly Gaussian on the 32 examples i.e. on this batch, so its called batch normalization
# mean, sd are just mathematical formulas and are perfectly differentiable
# the epsilon is a small fixed number and its just preventing a division by 0 in the case that the variance over our batch is exactly 0, so things will be more well behaved
# though we haven't done it below cuz its unlikely to happen in a batch, feel free to add epsilon here, 
# hpreact = bngain * (hpreact - bnmean_running) / (bnstd_running + epsilon) + bnbias
# we can just train this, but we won't achieve a very good result
# cuz we want it to be roughly Gaussian but only at initialization: we don't want to force hpreact to always be Gaussian
# we want to make it so that neural net can make it more diffused/sharp, make some tanh neurons more/less trigger happy
# so in the paper, look at 4th formula, we take the normalized inputs, then additionally scaling them by some gain and offsetting them by some bias to get final output
# so we will allow a batch normalization gain to be initialized at 1's

# bngain = torch.ones((1, n_hidden))
# bnbias = torch.zeros((1, n_hidden))
# then

# our batch normalization layer:
# hpreact = bngain * (hpreact - hpreact.mean(0, keepdim=True)) / hpreact.std(0, keepdim = True) + bnbias

# since at initialization bngain is initialized to 1 and bnbias to 0, each neurons firing values in this batch will be unit Gaussian no matter the distribution of hpreact coming in
# so we got what we want at initialization
# don't forget to include these parameters in the list of parameters as they will be trained by the backpropagation
# during optimization, we'll be able to backpropagate into bngain and bnbias and change them so that the network has full ability to do whatever it wants internally(not forcing Gaussian distribution)
12297
      0/ 200000: 3.3147
  10000/ 200000: 2.1984
  20000/ 200000: 2.3375
  30000/ 200000: 2.4359
  40000/ 200000: 2.0119
  50000/ 200000: 2.2595
  60000/ 200000: 2.4775
  70000/ 200000: 2.1020
  80000/ 200000: 2.2788
  90000/ 200000: 2.1862
 100000/ 200000: 1.9474
 110000/ 200000: 2.3010
 120000/ 200000: 1.9837
 130000/ 200000: 2.4523
 140000/ 200000: 2.3839
 150000/ 200000: 2.1987
 160000/ 200000: 1.9733
 170000/ 200000: 1.8668
 180000/ 200000: 1.9973
 190000/ 200000: 1.8347
train 2.0668270587921143
val 2.104844808578491
# previous loss log:
train 2.1270017623901367
val 2.169905662536621

# previous loss log:
train 2.069589138031006
val 2.1310746669769287

# previous loss log
train 2.0355966091156006
val 2.1026785373687744

# previous loss log
train 2.0376641750335693
val 2.106989622116089

# current loss log
train 2.0668270587921143
val 2.104844808578491
# our result is kinda comparable to those of previous cases
# we aren't expecting much improvements cuz we are dealing with a very simple neural net that has just a single hidden layer
# actually in this case, we were able to calculate what the scale of W should be to make the preactivations already have a rough Gaussian shape
# so the batch normalization is not doing much here
# but if we have a much deeper neural net that has different types of operations and there's residual connections
# it will be very very difficult to tune the scales of weight matrices such that all the activations throughout the neural net are roughly Gaussian
# and that will be quickly intractable (difficult to solve)
# but compared to that, it will be much easier to sprinkle batch normalization throughout the neural net
# its conventional to take the linear/convolutional layer and append a batch normalization layer right after it to control the scale of these activations at every point in neural net
# so we'll add batchnorm layers throughout the neural net, and it'll control the scale of activations throughout the neural net
# we don't need to worry about mathematics for weights of neural nets
# it significantly stabilizes the training
# Batch Normalization stabilizes training, but it comes with a strange tradeoff.

# Before BatchNorm:
# - Each input example passes through the network independently.
# - Hidden activations and logits depend ONLY on that example.
# - Forward pass is completely deterministic for a given input.

# We introduced mini-batches only for computational efficiency.
# - Examples in the batch were processed independently.
# - They did NOT mathematically affect one another.

# After BatchNorm:
# - Batch statistics (mean and variance) are computed across the WHOLE batch.
# - Therefore every example depends on every other example in that batch.
# - Examples become mathematically COUPLED during both the forward and backward pass.

# Consequence:
# - The hidden activations (h) for one example are no longer determined only by that example.
# - They also depend on which other examples happened to be sampled in the same batch.

# Since mini-batches are randomly sampled:
# - Batch mean and variance change every iteration.
# - Therefore normalized activations change slightly.
# - Hidden activations "jitter" from batch to batch.
# - Logits also jitter slightly.

# At first this seems like a bug:
# - The exact same input can produce slightly different activations depending on its batchmates.
# - This feels unnatural because the network is no longer purely deterministic.

# Surprisingly, this jitter actually helps training.

# Why?
# - The small randomness acts like NOISE.
# - Every training example is slightly perturbed each iteration.
# - Similar to a small form of data augmentation.
# - This makes memorizing exact training examples harder.

# This acts as a REGULARIZER.
# - Regularization reduces overfitting.
# - The network learns more general features instead of memorizing specific samples.
# - The injected noise improves generalization.

# So BatchNorm provides TWO major benefits:
# 1. Keeps activations well-behaved (stable distributions).
# 2. Introduces useful noise that regularizes training.

# The downside:
# - Examples inside a batch are mathematically coupled.
# - This can produce unintuitive behavior.
# - It introduces implementation complexity and subtle bugs.
# - Results can depend on batch size and batch composition.

# Because of these drawbacks, researchers developed alternatives that normalize
# each example independently instead of using batch statistics.

# Examples:
# - Layer Normalization
# - Instance Normalization
# - Group Normalization

# These methods avoid coupling different examples together.

# However...
# BatchNorm is still widely used because it simply works extremely well.
# Its combination of stable activations + regularization often gives better training.

# Main takeaway:
# BatchNorm is theoretically a little "weird" because examples influence each other,
# but that very weirdness injects useful noise, improves generalization, and is one
# reason it has remained so successful despite many newer normalization methods.
# Problem:
# During training, BatchNorm computes the mean and standard deviation
# from the CURRENT MINI-BATCH.

# This works because training always uses batches of examples.

# But after training, we want to deploy the model.
# During inference we may only have ONE input example.

# Question:
# How can BatchNorm normalize a single example if it expects
# statistics (mean/std) computed from an entire batch?

# We cannot estimate meaningful batch statistics from a single sample.
# Therefore we need a different strategy during inference.

# Solution (proposed in the original BatchNorm paper):
# After training, perform one calibration pass over the training set.

# During this calibration:
# - Disable gradient tracking (torch.no_grad()) since no learning happens.
# - Pass all training examples through the network.
# - Compute ONE global estimate of the BatchNorm mean.
# - Compute ONE global estimate of the BatchNorm standard deviation.

# These become the "running" (fixed) BatchNorm statistics.

# During inference:
# Instead of recomputing batch statistics every forward pass,
# always normalize using these fixed mean and std values.

# Training:
# mean = current batch mean
# std  = current batch std

# Inference:
# mean = fixed training-set estimate
# std  = fixed training-set estimate

# Benefits:
# - The network can process a single example.
# - Predictions become deterministic.
# - Results no longer depend on what other examples are in the batch.
# - Performance remains almost identical to training.

# torch.no_grad():
# - Disables gradient computation.
# - Saves memory and computation.
# - Used whenever we are only doing forward passes (evaluation/inference).

# Main takeaway:
# BatchNorm behaves differently during training and inference.
# - Training uses dynamically computed batch statistics.
# - Inference uses fixed statistics computed once after training.
# This allows the model to make sensible predictions on individual examples.
train 2.0668270587921143
val 2.1049270629882812
# previous loss log:
train 2.1270017623901367
val 2.169905662536621

# previous loss log:
train 2.069589138031006
val 2.1310746669769287

# previous loss log
train 2.0355966091156006
val 2.1026785373687744

# previous loss log
train 2.0376641750335693
val 2.106989622116089

# previous loss log
train 2.0668270587921143
val 2.104844808578491

# current loss log
train 2.0668270587921143
val 2.1049270629882812

Calibration and running statistics

# we got identical result, but the benefit now is that we can also forward a single example because the bnmean and bns.d. are fixed
# that said, nobody actually wants to estimate the mean and s.d. as a second step after training cuz everyone is lazy
# so the paper introduced one more idea: we can estimate the mean and s.d. in a running manner during training of the neural net
# then we can have single stage of training
12297
      0/ 200000: 3.3147
  10000/ 200000: 2.1984
  20000/ 200000: 2.3375
  30000/ 200000: 2.4359
  40000/ 200000: 2.0119
  50000/ 200000: 2.2595
  60000/ 200000: 2.4775
  70000/ 200000: 2.1020
  80000/ 200000: 2.2788
  90000/ 200000: 2.1862
 100000/ 200000: 1.9474
 110000/ 200000: 2.3010
 120000/ 200000: 1.9837
 130000/ 200000: 2.4523
 140000/ 200000: 2.3839
 150000/ 200000: 2.1987
 160000/ 200000: 1.9733
 170000/ 200000: 1.8668
 180000/ 200000: 1.9973
 190000/ 200000: 1.8347
# we hope that this bnmean_running and bnstd_running will be similar to the ones we calculated before after training, i.e. bnmean and bnstd
# if that happens, we won't need a second stage, cuz we will have combined the 2 stages, thus no work to do after training
# and actually, this is how it is implemented in batch normalization layer in pytorch
# during inference/testing, it will use the estimated running mean and s.d. of both hidden states
# running the code below to compare bnmean with bnmean_running, and bnstd with bnstd_running
tensor([[-2.3355, 0.6776, -0.9132, 1.0163, 1.0865, 1.0938, 1.7436, -2.1208, 0.5730, 1.4455, -1.6344, -2.7372, -0.4752, -0.1412, -0.0746, -1.1722, 0.6851, -2.6218, -0.1065, 1.6325, -0.7706, -0.3062, 0.0479, 0.6115, 1.1173, 0.2427, 2.0500, 0.5832, 0.8527, 1.7680, -0.3625, -0.8356, -0.0854, -0.5177, -0.3805, -1.0698, -0.0786, 0.3487, -0.5808, 0.9875, -0.4427, -1.3081, -0.2871, -0.2332, 0.6850, 0.6850, 2.0857, -0.7608, 2.3866, 1.8734, 0.8259, 0.2803, 1.8897, 0.4709, 0.6739, -1.8940, -0.0401, 0.4338, 1.3760, -0.8910, -0.4524, 1.1754, 0.5613, 0.6051, 1.5858, 1.2261, -1.0111, 2.1495, -0.6393, 0.0938, -0.2864, -0.4856, 0.9632, -1.0461, -2.9990, 0.6391, 1.4327, -0.1590, 0.0941, 0.5253, 0.2508, 1.2521, 2.0388, 0.6608, 0.0691, -0.0813, -1.6723, 0.2933, 2.2423, -0.0211, -0.6666, 1.4253, -0.8412, -1.2248, -1.0128, 0.2230, 0.2112, -0.3226, 0.1141, -0.6350, 0.1848, 0.1165, -1.3947, 0.2343, 0.2170, -0.3494, -0.3409, -0.1918, 0.9244, -0.8073, 0.7147, 0.2383, 0.3964, 1.2355, 2.8623, 2.0470, 0.8419, 0.8480, 0.3483, -0.3153, -1.0648, -1.4465, 0.3518, 1.1174, -1.1265, 0.0688, -0.2204, -0.4964, -0.8558, -1.0849, 2.8098, -1.2867, 0.6543, 1.8674, 0.9511, 1.0606, -0.7020, 1.8505, 0.1528, 0.4583, 1.6234, -0.1899, 1.9335, 0.2938, 0.4391, 0.2065, 1.1369, -0.6884, 0.1559, 0.5905, -1.5956, -0.2324, 1.7724, 1.2664, 0.9916, -0.4957, 1.4023, -1.6664, 0.0178, 0.4224, 0.5857, -0.4225, 0.9377, -1.1793, -0.4486, -0.7184, -0.3308, -0.4207, -2.1519, -0.1099, 1.1733, -2.1530, 0.1215, -0.8929, -0.9959, 0.9028, 1.3427, -0.7526, 1.3065, -1.5016, 1.8438, 0.9704, 0.8244, -0.7247, 0.3621, 0.0768, -0.9657, 0.1345, 1.8784, 0.9191, -0.5020, 1.6438, -0.7394, 0.2081, 0.1425, 1.2711, 1.8083, -0.8162, 0.2586, 1.2403]])
tensor([[-2.3539, 0.6872, -0.9000, 1.0159, 1.0894, 1.0862, 1.7389, -2.1356, 0.5608, 1.4246, -1.6445, -2.7426, -0.4861, -0.1510, -0.0687, -1.1549, 0.6891, -2.6399, -0.1283, 1.6240, -0.7732, -0.2864, 0.0467, 0.6119, 1.1172, 0.2433, 2.0542, 0.5778, 0.8515, 1.7729, -0.3741, -0.8385, -0.0831, -0.5198, -0.3816, -1.0698, -0.0781, 0.3370, -0.5768, 0.9935, -0.4507, -1.3313, -0.2895, -0.2299, 0.6877, 0.6936, 2.0835, -0.7759, 2.3804, 1.8613, 0.8117, 0.2735, 1.8802, 0.4705, 0.6656, -1.8962, -0.0420, 0.4356, 1.3924, -0.8906, -0.4676, 1.1688, 0.5539, 0.6001, 1.5853, 1.2103, -1.0171, 2.1421, -0.6330, 0.1071, -0.2926, -0.4831, 0.9505, -1.0144, -2.9925, 0.6268, 1.4404, -0.1574, 0.0955, 0.5159, 0.2487, 1.2400, 2.0104, 0.6695, 0.0768, -0.0851, -1.6768, 0.2964, 2.2374, -0.0100, -0.6669, 1.4356, -0.8431, -1.2317, -1.0220, 0.2201, 0.1928, -0.3261, 0.1108, -0.6206, 0.1795, 0.1089, -1.4007, 0.2215, 0.2301, -0.3369, -0.3340, -0.1849, 0.9342, -0.8288, 0.7119, 0.2475, 0.3813, 1.2447, 2.8427, 2.0338, 0.8294, 0.8458, 0.3483, -0.3078, -1.0773, -1.4394, 0.3424, 1.1274, -1.1252, 0.0692, -0.2308, -0.4936, -0.8471, -1.0785, 2.8080, -1.2824, 0.6541, 1.8701, 0.9662, 1.0696, -0.7051, 1.8326, 0.1624, 0.4782, 1.6376, -0.1962, 1.9364, 0.2986, 0.4300, 0.2062, 1.1390, -0.6907, 0.1571, 0.5883, -1.5947, -0.2230, 1.7796, 1.2689, 0.9813, -0.4950, 1.4052, -1.6645, 0.0249, 0.4152, 0.5742, -0.4044, 0.9335, -1.1684, -0.4574, -0.7229, -0.3192, -0.4214, -2.1397, -0.1036, 1.1620, -2.1602, 0.1196, -0.8847, -0.9970, 0.9115, 1.3360, -0.7411, 1.2991, -1.5061, 1.8278, 0.9732, 0.8192, -0.7259, 0.3556, 0.0714, -0.9718, 0.1299, 1.8912, 0.9107, -0.4951, 1.6493, -0.7601, 0.2008, 0.1606, 1.2586, 1.8110, -0.8021, 0.2453, 1.2369]])
# not identical but very very similar/close
tensor([[2.3929, 2.1364, 2.2663, 1.9944, 2.1860, 2.3736, 2.2576, 2.3451, 2.3578, 2.1045, 2.4973, 2.2571, 2.1519, 2.2017, 2.0989, 2.7105, 2.4423, 1.8902, 2.2073, 2.5144, 2.3232, 2.3402, 2.1268, 2.0214, 2.0374, 1.8407, 2.1634, 2.6021, 2.2403, 2.3329, 1.7083, 1.9046, 2.0957, 1.8207, 2.0848, 1.9323, 2.4885, 2.1671, 2.1170, 1.8611, 2.0108, 2.0911, 2.4064, 1.9754, 1.9694, 2.4172, 2.1965, 2.5617, 2.1547, 2.4972, 2.0511, 1.8491, 2.0182, 1.7755, 2.3971, 2.2095, 1.7725, 2.2930, 2.5639, 1.7747, 2.1165, 1.9382, 2.0266, 2.2968, 2.1514, 2.3390, 2.1540, 2.4904, 2.0355, 2.1785, 2.0923, 1.9757, 1.9920, 2.2562, 1.9101, 1.8626, 2.5723, 2.2043, 1.6819, 2.2513, 2.0126, 1.8353, 2.0170, 2.2116, 2.5047, 2.3529, 2.1391, 2.3848, 2.5823, 2.6848, 2.0914, 1.9556, 2.1099, 1.9680, 1.8658, 2.2201, 2.1905, 2.0695, 1.8927, 2.3433, 2.0515, 2.1435, 2.2730, 2.0000, 1.9994, 2.2907, 1.8655, 2.2061, 1.9252, 2.1527, 2.3877, 2.2976, 2.1634, 2.3319, 2.5755, 2.3501, 2.0690, 1.9381, 1.9241, 2.1024, 2.1037, 2.0721, 2.0400, 2.0679, 2.2010, 2.1545, 2.1613, 2.0952, 2.0100, 2.1792, 1.9268, 2.6127, 2.2322, 1.8481, 2.1136, 2.1434, 2.2190, 3.3364, 1.6564, 2.1655, 2.1305, 1.9825, 2.2345, 1.5322, 2.1059, 2.2582, 1.9830, 2.1161, 1.9503, 1.6744, 1.5991, 2.1524, 1.8803, 2.0463, 2.0388, 2.2879, 2.0508, 1.8912, 1.8859, 1.8473, 2.3096, 2.0570, 1.9480, 2.4088, 2.1612, 1.8219, 2.3046, 1.8478, 2.1150, 2.0714, 1.7050, 2.1585, 2.2464, 2.0182, 2.6454, 1.9193, 2.2797, 2.1573, 2.2906, 2.1067, 2.5027, 2.0867, 2.4159, 1.8866, 2.0085, 2.1816, 2.2023, 2.0658, 2.3380, 2.2764, 1.8873, 2.1168, 2.1975, 1.7636, 2.1140, 2.3538, 2.1273, 2.5774, 2.3533, 1.8955]])
tensor([[2.3689, 2.0998, 2.2499, 1.9599, 2.1515, 2.3429, 2.2431, 2.3104, 2.3309, 2.0823, 2.4680, 2.2253, 2.1192, 2.1805, 2.0655, 2.6865, 2.4191, 1.8718, 2.1882, 2.4989, 2.2903, 2.3143, 2.1068, 1.9938, 2.0172, 1.8189, 2.1352, 2.5721, 2.2250, 2.3027, 1.6857, 1.8987, 2.0912, 1.8024, 2.0771, 1.9073, 2.4760, 2.1356, 2.0873, 1.8440, 1.9877, 2.0602, 2.3704, 1.9412, 1.9464, 2.4033, 2.1706, 2.5047, 2.1319, 2.4826, 2.0177, 1.8195, 1.9937, 1.7548, 2.3758, 2.1857, 1.7500, 2.2775, 2.5494, 1.7520, 2.0970, 1.9272, 2.0154, 2.2870, 2.1270, 2.3166, 2.1311, 2.4586, 2.0074, 2.1552, 2.0607, 1.9617, 1.9790, 2.2357, 1.8885, 1.8480, 2.5581, 2.1731, 1.6619, 2.2316, 1.9924, 1.8106, 1.9824, 2.1808, 2.4859, 2.3152, 2.1121, 2.3617, 2.5755, 2.6760, 2.0648, 1.9299, 2.1034, 1.9445, 1.8592, 2.1894, 2.1640, 2.0496, 1.8632, 2.3143, 2.0495, 2.1165, 2.2337, 1.9762, 1.9748, 2.2615, 1.8477, 2.1645, 1.9084, 2.1207, 2.3601, 2.2709, 2.1375, 2.3012, 2.5627, 2.3287, 2.0472, 1.9111, 1.9039, 2.0788, 2.0843, 2.0565, 2.0248, 2.0328, 2.1764, 2.1329, 2.1387, 2.0756, 1.9939, 2.1565, 1.9034, 2.5821, 2.2012, 1.8329, 2.0939, 2.1211, 2.1896, 3.3192, 1.6280, 2.1359, 2.1058, 1.9597, 2.2206, 1.5127, 2.0692, 2.2279, 1.9567, 2.0889, 1.9375, 1.6576, 1.5770, 2.1411, 1.8382, 2.0148, 2.0216, 2.2586, 2.0242, 1.8674, 1.8542, 1.8282, 2.3054, 2.0362, 1.9262, 2.3913, 2.1477, 1.7948, 2.2873, 1.8242, 2.0812, 2.0489, 1.6935, 2.1370, 2.2291, 2.0005, 2.6261, 1.8986, 2.2510, 2.1332, 2.2671, 2.0839, 2.4491, 2.0653, 2.3852, 1.8683, 1.9850, 2.1662, 2.1688, 2.0450, 2.3288, 2.2488, 1.8815, 2.0983, 2.1695, 1.7452, 2.0874, 2.3238, 2.1115, 2.5528, 2.3462, 1.8781]])
# so now we replace bnmean and bnstd with bnmean_running and bnstd_running
train 2.06659197807312
val 2.1050572395324707
# this way, we've eliminated the need for calibration after training cuz we're doing it inline during training
# previous loss log:
train 2.1270017623901367
val 2.169905662536621

# previous loss log:
train 2.069589138031006
val 2.1310746669769287

# previous loss log
train 2.0355966091156006
val 2.1026785373687744

# previous loss log
train 2.0376641750335693
val 2.106989622116089

# previous loss log
train 2.0668270587921143
val 2.104844808578491

# previous loss log
train 2.0668270587921143
val 2.1049270629882812

# current loss log
train 2.06659197807312
val 2.1050572395324707
# one thing to notice: we're being wasteful here:

# forward pass
# emb = C[Xb] #embed the characters into vectors
# embcat = emb.view(emb.shape[0], -1) #concatenate the vectors
# hpreact = embcat @ W1 + b1 

# 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 

# look at us adding bias to hpreact. this bias is useless right now
# look this flow: add bias to hpreact, then calculate the mean for all neurons, then subtracting it later down
# so whatever bias we add up will get subtracted later and don't impact the rest of the calculations
# so if you look at b1.grad, it will be 0 cuz its subtracted out and doesn't have any effect

# so whenever you're using batch normalization layers, if you have any weight layer before a linear or convolutional layer or similar, 
# you're better off not using bias
# instead we have batch normalization bias and that bias is incharge of biasing this distribution instead of b1 that was used originally
# basically batch normalization has its own bias, and there's not point in using a bias before it cuz that will get subtracted out anyway
# its just that b1 will never get any gradient, it will never learn, it will just stay constant and it is wasteful but doesn't impact/harm anything
# refactoring of code and summary of batch normalization
12097
      0/ 200000: 3.3239
# batch normalization
# used to control statistics of activations in neural net
# activations are mathematical formulas applied to a neuron’s output to decide whether and how strongly that signal should be passed on to the next layer
# it is common to sprinkle batch normalization across the neural net, 
# and usually we place it after layers that have multiplications eg. linear layer or convolutional layer
# bngain, bnbias trained using backprop, and also has 2 buffers: mean and std that are not trained using std but updated by our own running update
# batch norm layer during training just
# calculates the mean and sd of activations over that batch
# then it centers that batch to be unit Gaussian
# then it offsets and scales it by the learned bias and gain
# and it also keeps track of mean and sd of inputs, and keeps track of running mean and sd which will later be used at inference/testing
# that allows us to forward individual examples at test time
# block that's used and repeated sequentially in series is called Bottleneck block(in actual source code of ResNet i guess)
# convolutional layer are similar to linear layer, but they are used for images so they have spatial structure
# wx+b on overlapping batches of input
# for very deep networks, relu is typically more better 
# basically, the general structure that is common is:
# a linear/convolutional layer
# followed by batch normalization layer
# followed by non-linearity layer eg. relu/tanh, and this repeats
# also saw use of bias=False in real world example cuz batch normalization has its own bias
# torch.nn.Linear()
# a linear layer performs a wx + b, y=xA^T+b.
# to initialize we need just fan_in, fan_out so that W1 can be initialized
# in above case fan_in = n_embd * block_size, fan_out = n_hidden to let know of the matrix size
# and you can set bias=False if your linear layer is followed by a batch normalization layer
# they're using normal distribution instead of Gaussian, instead of (5/3)/fan_in, they are just using 1/fan_in and between sqr root of 1/fan_in and its -ve
# its kind of similar to what we implemented
# no_of_features = 200#our case, and it is needed to initialize bngain, bnbias, bnmean_running, bnstd_running
# momentum used for running mean and running std. in docs, default is 0.1, but in our case we used 0.001 above
# for a large batch size, you can use higher momentum like 0.1
# but for our batch size = 32, mean and std might change around a lot, 
# and if momentum is high eg. 0.1, it might not help our value to settle/converge to actual mean/std of entire training set
# track_running_stats determine whether or not our batch norm layer does that step in training itself to prevent calibration after training
# device: cpu, gpu
# dtype: half precision, single precision, double precision

Activation and normalization notes

# ============================================
# Lecture Summary: Activation Statistics & Batch Normalization
# ============================================

# Core idea:
# The statistics of activations and gradients are extremely important for
# training neural networks, especially as models become larger and deeper.

# Output layer:
# - Poorly scaled final activations can produce overly confident wrong predictions.
# - Confident mispredictions create very large ("hockey-stick") losses.
# - Proper initialization reduces these extreme losses, allowing training to
#   spend its effort learning instead of correcting bad initial predictions.

# Hidden layers:
# - Activations should neither collapse to 0 nor explode to very large values.
# - Extreme activations interact badly with nonlinear activation functions
#   (e.g. tanh saturation -> vanishing gradients).
# - The goal is to keep activation distributions stable across all layers.
# - Ideally, activations remain roughly Gaussian with similar scale throughout
#   the network.

# Weight initialization:
# - Properly scaling weights and biases during initialization helps maintain
#   healthy activation distributions.
# - Good initialization gives a significant improvement in optimization.

# Limitation of initialization:
# - Initialization only fixes the network at the START of training.
# - As training progresses, parameters change and activation statistics drift.
# - In very deep networks with many different layer types, carefully choosing
#   initial weights is no longer enough to keep activations well-behaved.

# Solution:
# Introduce normalization layers.

# Common normalization methods:
# - Batch Normalization (BatchNorm)
# - Layer Normalization (LayerNorm)
# - Instance Normalization
# - Group Normalization

# Batch Normalization idea:
# - Compute the batch mean and standard deviation.
# - Normalize activations to have roughly zero mean and unit variance.
# - Since normalization is differentiable, gradients can still flow normally.

# Why BatchNorm needs extra parameters:
# - Normalization alone removes the network's freedom to choose activation scale.
# - Learnable gain (gamma) restores scaling.
# - Learnable bias (beta) restores shifting.
# - The network can therefore learn the optimal distribution if needed.

# BatchNorm complication:
# - Batch statistics depend on every example in the mini-batch.
# - Therefore examples become mathematically coupled during training.
# - Hidden activations depend not only on one input but also on its batchmates.

# Inference problem:
# - During deployment we often have only one example.
# - We cannot compute meaningful batch statistics from a single sample.

# Inference solution:
# - Estimate BatchNorm mean and std from the training set (running statistics).
# - Freeze these values.
# - Use the fixed statistics during inference.

# Practical implementation:
# - Modern BatchNorm layers maintain running estimates during training.
# - This removes the need for a separate calibration step after training.

# Downsides of BatchNorm:
# - Coupling examples causes unintuitive behavior.
# - Can introduce subtle implementation bugs.
# - Has historically been a common source of debugging headaches.

# Because of these issues:
# - Many modern architectures prefer LayerNorm or GroupNorm.
# - These normalize each example independently instead of across the batch.

# Historical importance:
# - BatchNorm (introduced around 2015) was a breakthrough.
# - It made training much deeper neural networks reliable.
# - Its main contribution was controlling activation statistics throughout
#   the network, leading to faster and more stable optimization.

# Looking ahead:
# - Future topics (e.g. Recurrent Neural Networks) are effectively extremely
#   deep networks when unrolled through time.
# - Keeping activations and gradients stable becomes even more critical there.
# - Understanding initialization, activation statistics, and normalization is
#   fundamental for successfully training deep neural networks.

Layers and diagnostic plots

# pytorch-ifying the code, continue from 1:09:29
# torch.nn.Linear is a fully connected layer that computes:
# y = W @ x + b
# where W (weights) and b (bias) are learnable parameters.

# fan_in = number of input features
# fan_out = number of output features

# Weights are initialized using Kaiming initialization (good default for deep nets).
# Biases are typically initialized to zeros.

# layer.parameters() returns only the learnable parameters (weight and bias).


# BatchNorm1d(num_features, eps, momentum)
# eps: small value added to avoid division by zero.
# momentum: controls how quickly running statistics are updated.

# BatchNorm behaves differently during training and inference.

# During training:
# - Normalize using the current batch's mean and variance.
# - Update running_mean and running_var.

# During inference (evaluation):
# - Normalize using the stored running_mean and running_var.
# - Do NOT update the running statistics.

# gamma (weight) and beta (bias) are learnable parameters.
# running_mean and running_var are buffers (not optimized by gradient descent).

# Buffers are updated manually using an Exponential Moving Average (EMA),
# so they are not returned by layer.parameters().

# The BatchNorm paper stores running VARIANCE (std^2), not running standard deviation.

# layer.out is NOT part of PyTorch.
# Karpathy adds it only to save activations for plotting and debugging.

# torch.no_grad() is used when updating running_mean/running_var because
# these updates should not be tracked by autograd or included in backpropagation.


# Tanh layer simply computes:
# y = tanh(x)
# It has no learnable parameters.


# Representing everything as layers (Linear, BatchNorm, Tanh) lets us:
# - stack them easily,
# - loop through them,
# - collect all parameters automatically.


# Gain is adjusted during initialization to help control activation statistics,
# especially in deeper networks.


# Nested list comprehension collects parameters from every layer:
# [p for layer in layers for p in layer.parameters()]


# retain_grad() tells PyTorch to keep gradients for intermediate activations.
# Normally, gradients are only stored for leaf tensors (model parameters).
# Karpathy keeps them to inspect and plot gradient statistics.
46497
      0/ 200000: 3.2962
layer 1 (      Tanh): mean -0.02, std 0.62, saturated: 3.50%
layer 3 (      Tanh): mean -0.00, std 0.48, saturated: 0.03%
layer 5 (      Tanh): mean +0.00, std 0.41, saturated: 0.06%
layer 7 (      Tanh): mean +0.00, std 0.35, saturated: 0.00%
layer 9 (      Tanh): mean -0.02, std 0.32, saturated: 0.00%
Text(0.5, 1.0, 'activation distribution')
<Figure size 2000x400 with 1 Axes>
# Visualize the FORWARD activations (outputs) of each tanh layer.

# We only inspect tanh layers because:
# - tanh outputs are bounded in [-1, 1], making them easy to visualize.
# - Saturation (outputs near ±1) is also easy to detect.

# For each tanh layer:
# - Compute the mean and standard deviation of its activations.
# - Compute the % of saturated neurons.

# Saturation is defined as:
# abs(tanh_output) > 0.97
# These neurons lie in the flat tails of tanh, where gradients nearly vanish.

# Plot a histogram of activations to see their distribution.
# Ideally, activations should be centered around 0 and not pile up near ±1.


# With gain = 5/3:
# - Activation distributions remain stable across layers.
# - Standard deviation stays roughly constant (~0.65).
# - Only about 5% of neurons are saturated.
# - This is a healthy operating point for tanh networks.


# Gain is applied after Kaiming initialization:
# weight *= gain
# It compensates for tanh shrinking (squashing) the activations.


# Without the gain (gain = 1):
# - Every tanh layer slightly shrinks the activations.
# - Standard deviation decreases layer by layer.
# - Activations slowly collapse toward 0.
# - Very little saturation occurs, but the signal becomes weaker.


# With too large a gain (e.g. gain = 3):
# - Activations become too large.
# - Many neurons saturate at ±1.
# - Too much saturation leads to vanishing gradients.


# Gain = 5/3 empirically balances these two extremes:
# - Prevents activations from shrinking.
# - Avoids excessive saturation.
# - Keeps activation statistics roughly constant across deep tanh networks.


# Karpathy notes:
# He doesn't know the exact mathematical derivation of 5/3,
# but empirically it works very well for Linear -> tanh -> Linear -> tanh networks.
layer 1 (      Tanh): mean -0.04, std 0.76, saturated: 21.97%
layer 3 (      Tanh): mean -0.01, std 0.72, saturated: 11.00%
layer 5 (      Tanh): mean +0.01, std 0.73, saturated: 13.00%
layer 7 (      Tanh): mean -0.05, std 0.73, saturated: 13.34%
layer 9 (      Tanh): mean +0.00, std 0.72, saturated: 10.53%
Text(0.5, 1.0, 'activation distribution')
<Figure size 2000x400 with 1 Axes>
# in the case above, if we have more layers, it will stabilize at around 0.65 and saturation will be roughly 5%
# why does this stabilize and give us a nice distribution? cuz gain = 5/3
# std shrinks and saturation comes to 0
layer 1 (      Tanh): mean +0.000024, std 3.353992e-03
layer 3 (      Tanh): mean +0.000012, std 3.157344e-03
layer 5 (      Tanh): mean -0.000004, std 2.925863e-03
layer 7 (      Tanh): mean +0.000036, std 2.715700e-03
layer 9 (      Tanh): mean +0.000020, std 2.308167e-03
Text(0.5, 1.0, 'activation distribution')
<Figure size 2000x400 with 1 Axes>
# in graph above, we're looking that all layers have roughly the same gradient
# things are not shrinking or exploding
# what happens if you comment out all tanh non-linearities, but keep everything else: eg. gain of 5/3 ,etc
# in visualization, you have to replace Tanh with Linear
# basically, what happens is that there will be rapid shrinking with no tanh fighting it back
# but why do we need tanh? why not just a stack of linear layers and just set gain = 1, it is also well behaved right(experiment if you want).
# but it is just a big linear sandwich and it collapses into a single layer in terms of representation power
# so, no matter how many layers you add, at the end you get just a linear transformation, it will just be the same Wx + b but with bigger w and b's
# i.e. its equivalent to using single layer if we don't use non-linearity like tanh
# so basically, tanh non-linearities allows us to turn this linear function into a neural net that can approximate any arbitrary function
# getting neural nets to train before these advanced optimizers like adam, residual connections was like balancing pencil on finger
# you had to carefully orchestrate everything, be very wary of precision of activations, gradients, statistics
# it was impossible to train very deep networks cuz you'd have to be very careful with initialization
# plots personally used by andrej to debug neural networks
# ============================================================
# Viz #3: Parameter Statistics
# ============================================================

# Analyze the model parameters (weights) and their gradients.
# Only weight matrices (2D tensors) are plotted.
# Biases and BatchNorm gamma/beta are skipped for simplicity.

# For each weight matrix:
# - Compute mean and standard deviation.
# - Plot a histogram of parameter values.
# - Compute the gradient-to-data ratio.

# Gradient-to-data ratio:
# std(grad) / std(weight)
#
# Measures how large the gradients are compared to the parameter values.
# If gradients are too large, parameter updates become unstable.
# If gradients are too small, learning becomes very slow.

# Ideally, gradients should be much smaller than the weights.
# Here they are roughly 1000x smaller (ratio ≈ 1e-3), which is reasonable.

# Observation:
# The final output layer is an outlier.
# Its gradients are about 10x larger than the other layers,
# meaning SGD updates this layer much faster initially.

# This happens because the final layer was intentionally initialized
# with smaller weights to make the initial softmax less confident.

# After some training (e.g. 1000 iterations),
# this imbalance mostly corrects itself as the weights adapt.
weight   (27, 10) | mean +0.000980 | std 1.189171e-02 | grad:data ratio 1.189149e-02
weight  (30, 100) | mean +0.000118 | std 1.005291e-02 | grad:data ratio 3.214556e-02
weight (100, 100) | mean +0.000033 | std 7.821212e-03 | grad:data ratio 4.653362e-02
weight (100, 100) | mean -0.000107 | std 6.655620e-03 | grad:data ratio 3.925851e-02
weight (100, 100) | mean -0.000017 | std 6.086041e-03 | grad:data ratio 3.605768e-02
weight (100, 100) | mean -0.000077 | std 5.075620e-03 | grad:data ratio 3.015269e-02
weight  (100, 27) | mean -0.000000 | std 2.056585e-02 | grad:data ratio 2.909910e-01
<Figure size 2000x400 with 1 Axes>
# ============================================================
# Viz #4: Update-to-Data Ratio
# ============================================================

# Gradient-to-data ratio isn't the whole story.
# The actual parameter update is:
#
# update = learning_rate * gradient
#
# So what really matters is:
#
# std(update) / std(weight)

# This measures how much each parameter changes every iteration
# relative to its current magnitude.

# Karpathy plots:
# log10(std(update) / std(weight))
#
# Using log10 makes it easy to compare values across layers.

# Ideal update ratio:
# around 1e-3 (log10 ≈ -3)

# Interpretation:
# log10 ≈ -3
# -> parameters change by about 0.1% each step (healthy)

# Much larger (e.g. -1):
# -> updates are too large
# -> training may become unstable

# Much smaller (e.g. -4 or lower):
# -> updates are tiny
# -> learning is too slow

# The final layer starts above the others because its weights were
# artificially shrunk during initialization.
# As training progresses, it gradually stabilizes.

# This plot is also useful for tuning the learning rate.

# Example:
# If learning rate = 0.001,
# all update ratios become much smaller than -3,
# indicating that training is unnecessarily slow.

# If update ratios are slightly above -3 (e.g. around -2.5),
# learning is a bit aggressive but often still reasonable.
<Figure size 2000x400 with 1 Axes>
# ============================================================
# Debugging with these plots
# ============================================================

# Properly initialized networks should show:
# - Stable activation distributions.
# - Stable gradient distributions.
# - Reasonable parameter histograms.
# - Update ratios around 1e-3.

# If initialization is wrong (e.g. no fan-in scaling):
# - Activations become heavily saturated.
# - Gradients become poorly distributed.
# - Parameter histograms become irregular.
# - Different layers learn at very different speeds.
# - Update ratios become much larger than expected.

# These visualization plots quickly reveal
# whether a network is well-calibrated or miscalibrated,
# making them powerful debugging tools during training.
# until now, it feels like balancing a pencil on a finger, now we introduce batch normalization
# typical place to put batch normalization is between linear layer and non-linearity
# but you can get similar results even if you place if after tanh
# its fine to place it at end after last linear layer and before the loss function
47551
      0/ 200000: 3.2870
layer 2 (      Tanh): mean -0.00, std 0.63, saturated: 2.62%
layer 5 (      Tanh): mean +0.00, std 0.64, saturated: 2.47%
layer 8 (      Tanh): mean -0.00, std 0.64, saturated: 2.16%
layer 11 (      Tanh): mean +0.00, std 0.65, saturated: 1.81%
layer 14 (      Tanh): mean -0.00, std 0.65, saturated: 1.78%
Text(0.5, 1.0, 'activation distribution')
<Figure size 2000x400 with 1 Axes>
layer 2 (      Tanh): mean -0.000000, std 3.682961e-03
layer 5 (      Tanh): mean -0.000000, std 3.262612e-03
layer 8 (      Tanh): mean +0.000000, std 2.973734e-03
layer 11 (      Tanh): mean +0.000000, std 2.741114e-03
layer 14 (      Tanh): mean +0.000000, std 2.584295e-03
Text(0.5, 1.0, 'activation distribution')
<Figure size 2000x400 with 1 Axes>
weight   (27, 10) | mean +0.000000 | std 1.019890e-02 | grad:data ratio 1.018851e-02
weight  (30, 100) | mean +0.000092 | std 8.238448e-03 | grad:data ratio 2.635845e-02
weight (100, 100) | mean +0.000031 | std 7.318127e-03 | grad:data ratio 4.377153e-02
weight (100, 100) | mean -0.000019 | std 6.662029e-03 | grad:data ratio 3.949899e-02
weight (100, 100) | mean -0.000019 | std 5.821521e-03 | grad:data ratio 3.468164e-02
weight (100, 100) | mean +0.000053 | std 5.445303e-03 | grad:data ratio 3.243371e-02
weight  (100, 27) | mean -0.000165 | std 1.046846e-02 | grad:data ratio 6.281275e-02
<Figure size 2000x400 with 1 Axes>
<Figure size 2000x400 with 1 Axes>
# we're going above -3 a little bit, but not by too much
# all parameters are training at roughly the same rate
# now if you replace gain 5/3 with eg. 0.2, it will almost have 0 effect cuz of batch normalization and everything looks the same
# except of course the the updates at last graph
# even though the forward and backward pass look okay 
# because of backward pass of batch norm and the scale of incoming activations interacts in the batchnorm and its backward pass
# this is changing the scale of the updates on these parameters
# so the gradients on the weights are affected
# so we still don't have the luxury to not use non-arbitrary gain 5/3, but everything else is significantly more robust
# that last graph's special case of arbitrary gain initialization is not shown here, you can test it if u wish
# and when we use batchnorm, we don't even have to normalize by fan_in sometimes, where we divided by fan_in ** 0.5
# remove it and see that because of batchnorm, it will behave relatively well, though little bit not preferred
# we can change the gain, but if we do so, we have to worry a little about the update scales 
# and making sure that learning rate is properly calibrated

Lessons from normalization

# summary:
# introduced batchnorm: stabilize very deep neural networks and their training
# pytorchify our code and wrap up into modules: Linear, BatchNorm1D, Tanh which can be stacked up into neural nets like lego building blocks
# these layers actually exist in pytorch, and if you import torch.nn
# you can just replace above code, eg. Linear with nn.Linear, and it will work
# cuz the api we built here is identical to the api that pytorch uses
# our implementation is also identical to that of pytorch
# understood diagnostic tools we can use to understand whether our neural network is in a good state dynamically
# looking at statistics, histograms and activations of forward pass activations, backward pass gradients, 
# the weights that are going to be updated as part of stochastic gradient descent, their means, sd, ratio of gradients to data
# and the last graph: updates to data, not look at single iteration of time, but growth over time
# and log(-3) is a good rough heuristic for what we want the ratio to be on that last graph
# if its very high, then the learning rate or the updates are too big, and same for small case
# we tried to beat the previous performance using batchnorm layer but it didn't work, cuz that isn't the bottle neck right now
# that was alternatively optimized by initializing the weights properly
# we suspect that the bottleneck is the context length of our context. we're just taking 3 characters to predict the 4th,
# and we need to go beyond that 
# and we need to look at more powerful architectures like recurrent neural networks and transformers to push the loss probabilities we are getting on this dataset
# i want to see outputs
      0/ 200000: 3.2870
  10000/ 200000: 2.3578
  20000/ 200000: 2.1042
  30000/ 200000: 1.9646
  40000/ 200000: 2.2145
  50000/ 200000: 2.2262
  60000/ 200000: 1.7340
  70000/ 200000: 2.1743
  80000/ 200000: 2.1895
  90000/ 200000: 1.8283
 100000/ 200000: 2.3646
 110000/ 200000: 2.2026
 120000/ 200000: 2.1039
 130000/ 200000: 1.8566
 140000/ 200000: 1.8049
 150000/ 200000: 1.9258
 160000/ 200000: 1.8757
 170000/ 200000: 1.8330
 180000/ 200000: 2.2324
 190000/ 200000: 2.0522
layer 2 (      Tanh): mean -0.01, std 0.69, saturated: 14.06%
layer 5 (      Tanh): mean -0.02, std 0.72, saturated: 16.84%
layer 8 (      Tanh): mean -0.00, std 0.75, saturated: 15.81%
layer 11 (      Tanh): mean +0.02, std 0.77, saturated: 16.75%
layer 14 (      Tanh): mean -0.02, std 0.79, saturated: 18.91%
Text(0.5, 1.0, 'activation distribution')
<Figure size 2000x400 with 1 Axes>
layer 2 (      Tanh): mean -0.000000, std 3.421682e-03
layer 5 (      Tanh): mean -0.000000, std 3.288253e-03
layer 8 (      Tanh): mean -0.000000, std 3.270637e-03
layer 11 (      Tanh): mean -0.000000, std 3.539113e-03
layer 14 (      Tanh): mean -0.000000, std 3.846485e-03
Text(0.5, 1.0, 'gradient distribution')
<Figure size 2000x400 with 1 Axes>
weight   (27, 10) | mean +0.000000 | std 1.496443e-02 | grad:data ratio 1.370268e-02
weight  (30, 100) | mean +0.000084 | std 7.878289e-03 | grad:data ratio 1.922244e-02
weight (100, 100) | mean +0.000060 | std 5.057851e-03 | grad:data ratio 2.049041e-02
weight (100, 100) | mean -0.000087 | std 5.180276e-03 | grad:data ratio 2.133905e-02
weight (100, 100) | mean +0.000043 | std 5.201565e-03 | grad:data ratio 2.196100e-02
weight (100, 100) | mean -0.000013 | std 5.238893e-03 | grad:data ratio 2.278768e-02
weight  (100, 27) | mean +0.000054 | std 9.173456e-03 | grad:data ratio 2.777520e-02
<Figure size 2000x400 with 1 Axes>
<Figure size 2000x400 with 1 Axes>
train 2.012324094772339
val 2.084045648574829
# previous loss log:
train 2.1270017623901367
val 2.169905662536621

# previous loss log:
train 2.069589138031006
val 2.1310746669769287

# previous loss log
train 2.0355966091156006
val 2.1026785373687744

# previous loss log
train 2.0376641750335693
val 2.106989622116089

# previous loss log
train 2.0668270587921143
val 2.104844808578491

# previous loss log
train 2.0668270587921143
val 2.1049270629882812

# previous loss log
train 2.06659197807312
val 2.1050572395324707

# current final loss log after implementing batch norm too 
# (won't be much diff cuz weights were initialized nicely before too which is good alternative but difficult to do in deep neural nets
train 2.012324094772339
val 2.084045648574829

Further experiments

torch.Size([3, 10])
carlah.
amelle.
khyriri.
reity.
skanya.
eja.
hube.
deliah.
jareei.
nellara.
chaiir.
kaleigh.
ham.
joce.
quintis.
lilah.
jadiquinte.
madiaryn.
kai.
eupitraylen.
# BONUS CONTENT NOT COVERED IN VIDEO
Loading...
<Figure size 1000x500 with 1 Axes>
a std: 0.9875972270965576
b std: 1.0006722211837769
c std: 31.01241683959961
-----
c grad std: 0.9782556295394897
a grad std: 30.8818302154541
b grad std: 0.9666601419448853
inp std:  0.9875972270965576
w std:  1.0006722211837769
x std:  31.01241683959961
out std:  1.0
------
out grad std:  0.9782556295394897
x grad std:  0.031543977558612823
w grad std:  0.031169468536973
inp grad std:  0.9953053593635559