Jovian
⭐️
Sign In

Note: This is a mirror of the official fast.ai notebook for the DSNet Study Group, please refer the official course repo for the latest notebooks.

In [ ]:
import torch

Why you need a good init

To understand why initialization is important in a neural net, we'll focus on the basic operation you have there: matrix multiplications. So let's just take a vector x, and a matrix a initiliazed randomly, then multiply them 100 times (as if we had 100 layers).

In [ ]:
x = torch.randn(512)
a = torch.randn(512,512)
In [ ]:
for i in range(100): x = a @ x
In [ ]:
x.mean(),x.std()
Out[]:
(tensor(nan), tensor(nan))

The problem you'll get with that is activation explosion: very soon, your activations will go to nan. We can even ask the loop to break when that first happens:

In [ ]:
x = torch.randn(512)
a = torch.randn(512,512)
In [ ]:
for i in range(100): 
    x = a @ x
    if x.std() != x.std(): break
In [ ]:
i
Out[]:
28

It only takes 27 multiplications! On the other hand, if you initialize your activations with a scale that is too low, then you'll get another problem:

In [ ]:
x = torch.randn(512)
a = torch.randn(512,512) * 0.01
In [ ]:
for i in range(100): x = a @ x
In [ ]:
x.mean(),x.std()
Out[]:
(tensor(0.), tensor(0.))

Here, every activation vanished to 0. So to avoid that problem, people have come with several strategies to initialize their weight matices, such as:

  • use a standard deviation that will make sure x and Ax have exactly the same scale
  • use an orthogonal matrix to initialize the weight (orthogonal matrices have the special property that they preserve the L2 norm, so x and Ax would have the same sum of squares in that case)
  • use spectral normalization on the matrix A (the spectral norm of A is the least possible number M such that torch.norm(A@x) <= M*torch.norm(x) so dividing A by this M insures you don't overflow. You can still vanish with this)

The magic number for scaling

Here we will focus on the first one, which is the Xavier initialization. It tells us that we should use a scale equal to 1/math.sqrt(n_in) where n_in is the number of inputs of our matrix.

In [ ]:
import math
In [ ]:
x = torch.randn(512)
a = torch.randn(512,512) / math.sqrt(512)
In [ ]:
for i in range(100): x = a @ x
In [ ]:
x.mean(),x.std()
Out[]:
(tensor(-0.0171), tensor(0.9270))

And indeed it works. Note that this magic number isn't very far from the 0.01 we had earlier.

In [ ]:
1/ math.sqrt(512)
Out[]:
0.044194173824159216

But where does it come from? It's not that mysterious if you remember the definition of the matrix multiplication. When we do y = a @ x, the coefficients of y are defined by

\[y_{i} = a_{i,0} x_{0} + a_{i,1} x_{1} + \cdots + a_{i,n-1} x_{n-1} = \sum_{k=0}^{n-1} a_{i,k} x_{k}\]

or in code:

y[i] = sum([c*d for c,d in zip(a[i], x)])

Now at the very beginning, our x vector has a mean of roughly 0. and a standard deviation of roughly 1. (since we picked it that way).

In [ ]:
x = torch.randn(512)
x.mean(), x.std()
Out[]:
(tensor(0.0144), tensor(1.0207))

NB: This is why it's extremely important to normalize your inputs in Deep Learning, the intialization rules have been designed with inputs that have a mean 0. and a standard deviation of 1.

If you need a refresher from your statistics course, the mean is the sum of all the elements divided by the number of elements (a basic average). The standard deviation represents if the data stays close to the mean or on the contrary gets values that are far away. It's computed by the following formula:

\[\sigma = \sqrt{\frac{1}{n}\left[(x_{0}-m)^{2} + (x_{1}-m)^{2} + \cdots + (x_{n-1}-m)^{2}\right]}\]

where m is the mean and \(\sigma\) (the greek letter sigma) is the standard deviation. Here we have a mean of 0, so it's just the square root of the mean of x squared.

If we go back to y = a @ x and assume that we chose weights for a that also have a mean of 0, we can compute the standard deviation of y quite easily. Since it's random, and we may fall on bad numbers, we repeat the operation 100 times.

In [ ]:
mean,sqr = 0.,0.
for i in range(100):
    x = torch.randn(512)
    a = torch.randn(512, 512)
    y = a @ x
    mean += y.mean().item()
    sqr  += y.pow(2).mean().item()
mean/100,sqr/100
Out[]:
(0.01184942677617073, 516.7942666625977)

Now that looks very close to the dimension of our matrix 512. And that's no coincidence! When you compute y, you sum 512 product of one element of a by one element of x. So what's the mean and the standard deviation of such a product? We can show mathematically that as long as the elements in a and the elements in x are independent, the mean is 0 and the std is 1. This can also be seen experimentally:

In [ ]:
mean,sqr = 0.,0.
for i in range(10000):
    x = torch.randn(1)
    a = torch.randn(1)
    y = a*x
    mean += y.item()
    sqr  += y.pow(2).item()
mean/10000,sqr/10000
Out[]:
(0.013266791818584533, 0.9896199178691623)

Then we sum 512 of those things that have a mean of zero, and a mean of squares of 1, so we get something that has a mean of 0, and mean of square of 512, hence math.sqrt(512) being our magic number. If we scale the weights of the matrix a and divide them by this math.sqrt(512), it will give us a y of scale 1, and repeating the product has many times as we want won't overflow or vanish.

Adding ReLU in the mix

We can reproduce the previous experiment with a ReLU, to see that this time, the mean shifts and the standard deviation becomes 0.5. This time the magic number will be math.sqrt(2/512) to properly scale the weights of the matrix.

In [ ]:
mean,sqr = 0.,0.
for i in range(10000):
    x = torch.randn(1)
    a = torch.randn(1)
    y = a*x
    y = 0 if y < 0 else y.item()
    mean += y
    sqr  += y ** 2
mean/10000,sqr/10000
Out[]:
(0.313308998029304, 0.4902977162997965)

We can double check by running the experiment on the whole matrix product.

In [ ]:
mean,sqr = 0.,0.
for i in range(100):
    x = torch.randn(512)
    a = torch.randn(512, 512)
    y = a @ x
    y = y.clamp(min=0)
    mean += y.mean().item()
    sqr  += y.pow(2).mean().item()
mean/100,sqr/100
Out[]:
(9.005213165283203, 257.3701454162598)

Or that scaling the coefficient with the magic number gives us a scale of 1.

In [ ]:
mean,sqr = 0.,0.
for i in range(100):
    x = torch.randn(512)
    a = torch.randn(512, 512) * math.sqrt(2/512)
    y = a @ x
    y = y.clamp(min=0)
    mean += y.mean().item()
    sqr  += y.pow(2).mean().item()
mean/100,sqr/100
Out[]:
(0.5643280637264252, 1.0055165421962737)

The math behind is a tiny bit more complex, and you can find everything in the Kaiming and the Xavier paper but this gives the intuition behing those results.

In [1]:
import jovian
In [ ]:
jovian.commit()
[jovian] Saving notebook..
In [ ]: