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.

Hierarchical context and WaveNet

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.

Growing the context

# we want to complexify our MLP character level language model from earlier ones
# we want to take more than 3 input characters
# we don't want to just feed the inputs into a single hidden layer because it squashes too much information too quickly
# we want a deeper model that progressively fuses this information to make its guess about the next character in a sequence
# wavenet: it is also a language model basically but it tries to predict audio sequences instead of character level sequences
# but fundamentally, the modeling setup is identical, and it tries to predict the next character in the sequence
['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([182580, 8]) torch.Size([182580])
torch.Size([22767, 8]) torch.Size([22767])
torch.Size([22799, 8]) torch.Size([22799])
... --> e
..e --> b
.eb --> r
ebr --> i
bri --> m
rim --> a
ima --> .
... --> h
..h --> i
.hi --> l
hil --> t
ilt --> o
lto --> n
ton --> .
... --> j
..j --> h
.jh --> e
jhe --> n
hen --> e
ene --> .
........ --> t
.......t --> a
......ta --> u
.....tau --> r
....taur --> e
...taure --> n
..tauren --> .
........ --> s
.......s --> u
......su --> l
.....sul --> e
....sule --> m
...sulem --> a
..sulema --> n
.suleman --> .
........ --> z
.......z --> e
......ze --> r
.....zer --> e
....zere --> n
12097
      0/ 200000: 3.3062
  10000/ 200000: 2.9745
  20000/ 200000: 1.9789
  30000/ 200000: 2.6006
  40000/ 200000: 2.0836
  50000/ 200000: 2.2505
  60000/ 200000: 2.4109
  70000/ 200000: 2.6298
  80000/ 200000: 2.5554
  90000/ 200000: 1.9270
 100000/ 200000: 1.8749
 110000/ 200000: 2.1795
 120000/ 200000: 2.0267
 130000/ 200000: 2.1801
 140000/ 200000: 1.9531
 150000/ 200000: 1.9738
 160000/ 200000: 2.0982
 170000/ 200000: 2.5167
 180000/ 200000: 2.5942
 190000/ 200000: 2.3027
[<matplotlib.lines.Line2D at 0x7a519468e3c0>]
<Figure size 640x480 with 1 Axes>
train 2.058544397354126
val 2.1069703102111816
# validation loss of 2.1069 is fairly good but can be better
noorah.
mit.
shud.
rin.
ristina.
lari.
espinzier.
carkyon.
elyn.
coriah.
sasyleightohalesplenceiomina.
skinnne.
aoryn.
hohby.
miques.
ajan.
kay.
mes.
cael.
crisseie.
[<matplotlib.lines.Line2D at 0x7a5194712f90>]
<Figure size 640x480 with 1 Axes>
[0.5193337202072144, 0.5132609009742737, 0.5081090927124023, 0.5117111802101135, 0.4967299699783325, 0.4925733506679535, 0.47202202677726746, 0.47907018661499023, 0.48941266536712646, 0.50093674659729]
tensor([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
tensor([[0, 1, 2, 3, 4],
        [5, 6, 7, 8, 9]])
tensor([[0, 1],
        [2, 3],
        [4, 5],
        [6, 7],
        [8, 9]])
torch.Size([200000])
torch.Size([200, 1000])
torch.Size([200, 1])
[<matplotlib.lines.Line2D at 0x7a519477c2f0>]
<Figure size 640x480 with 1 Axes>
# this graph is a lot better
# the second vertical fall that starts at around x=150 and y=0.33 is the learning rate decay
# learning rate decay allowed us to settle into the local minimum of this optimization

A modular model

# next, we fix our code for forward pass to make it more modular and organized
# we organized some layers(eg. linear, etc) inside the layers list but not embedding and viewing operation
# we have this embedding table C and we are indexing at the integers inside batch/tensor XB, 
# so that's an embedding table lookup done with simple indexing
# and the view operation just rearranges the character embeddings and stretches them out into a row
# and it just does the concatenation operation, and its efficient cuz no memory is created/copied, we're just re-representing how we view that tensor
# thus, we create modules for the embedding operation and the flattening operation
# these Embedding, Flatten also exist in pytorch
22097
      0/ 200000: 3.2924
# next improvement: 
# currently, we are maintaining all of our modules in a naked list of layers, and we want to simplify this as well
# we introduce the concept of pytorch containers in torch.nn
# containers are basically a way of organizing layers into lists/dicts/etc
# there is something called "sequential" inside containers which maintains a list of layers, and is a module class in pytorch
# and it just passes a given input through all the layers sequentially exactly as we're doing here, lets write our own sequential
# o/p of one will be i/p to next, and so on until end
22097
      0/ 200000: 3.5336
  10000/ 200000: 2.3646
  20000/ 200000: 2.4790
  30000/ 200000: 2.2761
  40000/ 200000: 2.0539
  50000/ 200000: 2.5657
  60000/ 200000: 2.5574
  70000/ 200000: 1.8780
  80000/ 200000: 1.8933
  90000/ 200000: 1.6215
 100000/ 200000: 2.3156
 110000/ 200000: 2.1761
 120000/ 200000: 2.0617
 130000/ 200000: 2.0571
 140000/ 200000: 2.5551
 150000/ 200000: 2.1006
 160000/ 200000: 1.9694
 170000/ 200000: 2.2940
 180000/ 200000: 2.2346
 190000/ 200000: 1.9638
      0/ 200000: 3.5614
  10000/ 200000: 2.0009
  20000/ 200000: 1.9863
  30000/ 200000: 2.4963
  40000/ 200000: 2.2945
  50000/ 200000: 2.2080
  60000/ 200000: 2.3371
  70000/ 200000: 1.7947
  80000/ 200000: 1.9881
  90000/ 200000: 2.2709
 100000/ 200000: 2.4245
 110000/ 200000: 1.9647
 120000/ 200000: 2.0879
 130000/ 200000: 2.2980
 140000/ 200000: 2.5866
 150000/ 200000: 1.9837
 160000/ 200000: 1.8209
 170000/ 200000: 1.8292
 180000/ 200000: 2.0675
 190000/ 200000: 2.0119
# if you forget to run the cell to set layer.training=False after training, and when you want to test
# you'll get error or incorrect results
# eg. in test, we are passing an input which is a batch of just a single example 
# and if we pass just a single example into batchnorm in training mode, you'll estimate the variance using the input
# and the variance of a single number is nan cuz variance/s.d. is a measure of the spread
# and we won't see the issue with loss, it will be evaluated but give the wrong result cuz batchnorm is still in traning mode if that cell above is not run
# cuz in testing, it will use the sample statistics of batch whereas we want to use the runningmean and runningvar inside batchnorm
# thus, if you don't maintain state properly, you'll get bug
/tmp/ipykernel_47933/2552686865.py:2: UserWarning: var(): degrees of freedom is <= 0. Correction should be strictly less than the reduction factor (input numel divided by output numel). (Triggered internally at /pytorch/aten/src/ATen/native/ReduceOps.cpp:1879.)
  torch.var(torch.tensor([8.0]))
tensor(nan)
train 1.9230281114578247
val 2.0320310592651367
ivya.
fadily.
thio.
masan.
emo.
maayannessa.
gaverley.
danaiif.
jazum.
carleon.
jir.
joan.
galoniiah.
jas.
vishylaharian.
daravio.
orven.
mickela.
leigh.
zeffeaustyla.
melar.
lyri.
gwosen.
darryn.
zakayah.
elsha.
taliyana.
jayah.
ziyah.
hamilinse.
chann.
shadualee.
aladay.
maceel.
hamby.
dweon.
suhaya.
kaylin.
covranni.
gitir.

Comparing context lengths

# 3 character context:
train 2.0593066215515137
val 2.1068434715270996

8 character context:
train 1.9230281114578247
val 2.0320310592651367
# since these 2 losses are very similar to each other, we have a sense that they're not overfitting too much 
# and we can make additional progress in our performance by scaling up the size of the neural network 
# in our current architecture, we are taking some characters (3) going into a single hidden layer, then going to prediction of next character
# the problem with this: we can't make this bigger in a productive way
# cuz we're crushing all characters into a single layer all the way at the beginning
# and even if we make it bigger by eg. adding neurons, its still silly to squash all that info fast in a single step
# so we'd want our network to look more like the following in the wavenet

Progressively combining characters

# Problem with the current architecture:
# - All characters are crushed into a single layer very early.
# - Even making this layer wider still squashes too much information at once.

# WaveNet-style idea:
# - Preserve information longer by progressively combining the context.
# - Fuse 2 characters → bigram representation.
# - Fuse 2 bigrams → 4-character representation.
# - Fuse 2 four-grams → 8-character representation.
# - Continue this in a tree-like, hierarchical manner.

# This creates a deeper network where context is gradually fused as we go deeper.
# In WaveNet, this is implemented using dilated causal convolution layers.
# "Dilated" and "causal" are mainly implementation details for efficiency;
# the key idea to understand is PROGRESSIVE FUSION.

# Goal:
# - Build a deeper architecture that progressively fuses:
#   characters → bigrams → 4-grams → 8-grams → ...
# - At each level, fuse only two consecutive elements.
# upon increasing context length / block_size from 3 to 8, and running it without any change, there was significant improvement in loss as you can see above
# it increases no of parameters obviously
# continue from 21:35
torch.Size([4, 8])
tensor([[ 0, 0, 0, 11, 1, 13, 18, 25], [ 0, 0, 0, 0, 0, 25, 1, 14], [ 0, 0, 0, 0, 0, 0, 0, 20], [ 0, 0, 0, 0, 2, 5, 3, 11]])
torch.Size([4, 8, 10])
torch.Size([4, 80])
torch.Size([4, 200])
torch.Size([4, 200])
torch.Size([4, 5, 2, 200])
# what happens here is that matrix mul works on the last dimension only, and previous dimensions remain unchanged
# and we can use this on our network now
# remember that we have 8 characters coming in: 1 2 3 4 5 6 7 8 and 
# we don't want to flatten all of it out into a large eight-dimensional vector: remember concept of wavenet
# we want to group it like this: (1 2) (3 4) (5 6) (7 8), we want to create four sub groups and process these bigram groups in parallel
# we don't want 8 characters coming in all at once, 
# we want 2 characters coming in on the first layer, and these 2 characters should be fused
# so we don't want (ax8) x (8xb), we want (a x 4 x 2) x (2 x b)
# (torch.randn(4, 80) @ torch.randn(80, 200) + torch.randn(200)).shape
# (torch.randn(4, 4, 20) @ torch.randn(20, 200) + torch.randn(200)).shape is what we want
# but to do this, we want the flatten layer to also output (4, 4, 20) instead of (4, 80)
# just pack in every 2 consecutive characters on the last dimension
# so we have to change Linear layer: should expect 20 instead of 80
# also, we have to change Flatten layer: should output (4, 4, 20) instead of (4, 80)
torch.Size([4, 8, 10])
# modify our flatten layer code, currently it does: self.out = x.view(x.shape[0], -1)
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
[1, 2, 3, 4, 5, 6, 7, 8, 9]
[0, 2, 4, 6, 8]
[0, 5]
[1, 3, 5, 7, 9]
[0, 2, 4, 6, 8]
torch.Size([4, 4, 20])
tensor(True)
# so no need to make explicit call to torch.cat, i.e. concatenate, we can just take input tensor and view it in whatever way we want
# we don't want to stretch things out with -1, we want to create 3d array, fuse eg. 2 consecutive vectors and ask dimension
# torch.squeeze: Returns a tensor with all specified dimensions of input of size 1 removed.
# For example, if input is of shape: (A×1×B×C×1×D)(A×1×B×C×1×D) then the input.squeeze() will be of shape: (A×B×C×D)(A×B×C×D).
# When dim is given, a squeeze operation is done only in the given dimension(s). If input is of shape: (A×1×B)(A×1×B), 
# squeeze(input, 0) leaves the tensor unchanged, but squeeze(input, 1) will squeeze the tensor to the shape (A×B)(A×B).

Grouping and flattening

22097
torch.Size([4, 8])
tensor([[ 0, 0, 0, 0, 0, 13, 1, 18], [ 0, 0, 0, 8, 1, 12, 5, 5], [ 0, 0, 0, 1, 13, 2, 5, 18], [ 0, 0, 0, 19, 8, 1, 11, 25]])
Embedding : (4, 8, 10)
FlattenConsecutive : (4, 80)
Linear : (4, 200)
BatchNorm1d : (4, 200)
Tanh : (4, 200)
Linear : (4, 27)
# now we achieve what we want to
170897
torch.Size([4, 8])
tensor([[ 0, 0, 0, 0, 0, 0, 8, 21], [ 0, 0, 0, 0, 0, 0, 0, 1], [ 0, 0, 0, 0, 0, 0, 1, 2], [ 5, 4, 4, 9, 14, 7, 20, 15]])
Embedding : (4, 8, 10)
FlattenConsecutive : (4, 4, 20)
Linear : (4, 4, 200)
BatchNorm1d : (4, 4, 200)
Tanh : (4, 4, 200)
FlattenConsecutive : (4, 2, 400)
Linear : (4, 2, 200)
BatchNorm1d : (4, 2, 200)
Tanh : (4, 2, 200)
FlattenConsecutive : (4, 400)
Linear : (4, 200)
BatchNorm1d : (4, 200)
Tanh : (4, 200)
Linear : (4, 27)
torch.Size([4, 27])
# our network now corresponds to this: blocksize = 8, layercount = 3
# now we chose n_hidden = 68, because its about the same parameters as before: approx 20,000, we want to compare performance increase by use of wavenet
# rather than just improve loss cuz we're learning ofcourse
# we want to see if the parameters are utilized in a more efficient architecture
22397
train 3.471845865249634
val 3.4728987216949463

Training the hierarchy

      0/ 200000: 2.0666
# even running for 200,000 iterations, there wasn't much difference
# we found that there was not much difference cuz a bug exists in batchnorm layer, lets fix it first then train our model finally
Embedding : (22767, 8, 10)
FlattenConsecutive : (22767, 4, 20)
Linear : (22767, 4, 68)
BatchNorm1d : (22767, 4, 68)
Tanh : (22767, 4, 68)
FlattenConsecutive : (22767, 2, 136)
Linear : (22767, 2, 68)
BatchNorm1d : (22767, 2, 68)
Tanh : (22767, 2, 68)
FlattenConsecutive : (22767, 136)
Linear : (22767, 68)
BatchNorm1d : (22767, 68)
Tanh : (22767, 68)
Linear : (22767, 27)
# look here, batchnorm receives input tensor of shape axbxc:
# Linear : (22767, 4, 68)
# BatchNorm1d : (22767, 4, 68)
# at the time we wrote batchnorm, we assumed that x is 2 dimensional (batch_size x d)
# so we reduced the mean and the variance over the 0th dimension as x.mean(0), x.var(0)
# though there are a dimensions, we're doing mean only over the 0th dimension
# then why's batchnorm working and not getting any errors?: cuz everything broadcast properly, but not how we want it to
# # inside class BatchNorm1d:
#  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

Diagnosing the normalization dimensions

torch.Size([22767, 4, 68])
torch.Size([1, 4, 68])
# look at docs of torch.mean
# parameters: dim (int or tuple of ints, optional) – the dimension or dimensions to reduce. If None, all dimensions are reduced.
# we can pass a tuple instead of single dimension
# fix is pretty simple, just include both dimensions
#  def __call__(self, x):

#         # calculate the forward pass
#         if self.training:
                # if x.ndim == 2:
                #     dim = 0 #same as prev
                # elif x.ndim == 3:
                #     dim = (0,1)
                # and we get error for other dim tensor, which is good
    #             xmean = x.mean(dim, keepdim=True) #batch mean
    #             xvar = x.var(dim, keepdim=True) #batch variance
# but here, we're departing from api of pytorch by a little, pytorch's takes both 2 and 3 dimension as input
# pytorch assumes no of channels will always be in middle i.e. first dimension, but we're assuming it to be in last dimension eg. 1 or 2
# and some batch dimension occur beforehand
# pytorch expects (N, C) or (N, C, L)
# we expect (N, C) or (N, L, C)
# N=batch size, C=no of channels, L=sequence length
# now our complete changes
22397
Embedding : (22767, 8, 10)
FlattenConsecutive : (22767, 4, 20)
Linear : (22767, 4, 68)
BatchNorm1d : (22767, 4, 68)
Tanh : (22767, 4, 68)
FlattenConsecutive : (22767, 2, 136)
Linear : (22767, 2, 68)
BatchNorm1d : (22767, 2, 68)
Tanh : (22767, 2, 68)
FlattenConsecutive : (22767, 136)
Linear : (22767, 68)
BatchNorm1d : (22767, 68)
Tanh : (22767, 68)
Linear : (22767, 27)
torch.Size([1, 1, 68])

Results after the correction

      0/ 200000: 3.6319
  10000/ 200000: 2.2179
  20000/ 200000: 2.2195
  30000/ 200000: 1.7254
  40000/ 200000: 1.8951
  50000/ 200000: 2.1006
  60000/ 200000: 1.7188
  70000/ 200000: 2.3980
  80000/ 200000: 2.4779
  90000/ 200000: 2.5653
 100000/ 200000: 2.2360
 110000/ 200000: 2.4323
 120000/ 200000: 2.1735
 130000/ 200000: 1.8321
 140000/ 200000: 1.8785
 150000/ 200000: 2.0539
 160000/ 200000: 1.7770
 170000/ 200000: 2.4599
 180000/ 200000: 1.9659
 190000/ 200000: 2.1477
[<matplotlib.lines.Line2D at 0x73a9502d30e0>]
<Figure size 640x480 with 1 Axes>
train 1.9126217365264893
val 2.0188381671905518
# performance log:
# 3 character context:
train 2.0593066215515137
val 2.1068434715270996

8 character context:
train 1.9230281114578247
val 2.0320310592651367

current with wavenet and fix bug in batchnorm:
train 1.9126217365264893
val 2.0188381671905518

Implementation reflections

kamon.
wlihanna.
franco.
jasler.
themi.
faynley.
matharynni.
blaylah.
hason.
celvi.
ajusleana.
kalypsa.
lanah.
marles.
conxann.
marna.
hozes.
shier.
mylen.
abreara.
# now, push our limits
# n_embd = 24, n_hidden = 128 to get 76000 parameters, training takes a lot longer, we can't bro, atleast not now, our laptop can't take it
# andrej got validation performance of 1.99
# but we're still in the dark about correct setting of the hyperparameters, learning rates, etc
# in our plot, we want to look at both training and validation loss, but currently, we're only looking at the training loss
# we implemented the architecture from the wavenet paper, but not forward pass circuit with complicated linear layer, residual connections, skip connections
# we want to preview, what we've done here relates to convolutional neural networks as used in wavenet paper
# use of convolutions is strictly for efficiency, it doesn't change the implemented model
........ --> h
.......h --> i
......hi --> l
.....hil --> t
....hilt --> o
...hilto --> n
..hilton --> .
........ --> j
torch.Size([8])
torch.Size([1, 27])
torch.Size([8, 27])
# convolution is a "for loop"
# convolution allows us to forward Linear layers efficiently over space
# convolutions allow you to slide this model efficiently over the input sequence
# so this for Loop can be done not outside in Python but inside of kernels in Cuda
# and this for Loop gets hidden into the convolution
# we've implemented a single black structure 
# we've implemented that and calculated a single output i.e. a single example 
# but what convolutions allow you to do is it allows you to take this black structure and kind of like slide it over the input sequence 
# and calculate all of these orange outputs at the same time 
# that corresponds to calculating all of these outputs at all the positions of the name with 8 examples at the same time
# why is this efficient?
# the for Loop is inside the Cuda kernels
# look at the variable reuse in the graph for some circles
# one node is the right child for some node and the left child for some other node elsewhere
# and we've pytorchified our code, kind of implemented torch.nn
# pytorch documentation is kinda incomplete, not so properly maintained, inconsistent, etc so finger crossed
# spend most of time making sure shapes of tensors are correct