Normalization refers to the transformation of intermediate representations so that their numerical scale can be controlled. Gradient based updates may require small updates along some directions while still making large errors along others. Example:

Norm_ex

Here the features have very different variances hence the contours of the loss are highly elongated. In such scenario, Gradient descent tends to oscillate or descent slowly because the curvature is very different along different directions. After normalizing:

Norm_ex_after

After standardizing the inputs, the loss contours become more balanced and closer to circular. Normalization improves the conditioning of the optimization problem that makes Gradient-based optimization more efficient increase the speed of convergence.

Batch Normalization (BatchNorm)

We normalize the activations using the examples of a mini-batch. Suppose a particular feature has values for the examples in the batch. BatchNorm computes mean and variance as follows:

each activation is then standardized:

is added to avoid division by zero error. After this we do:

where both are learned parameters (scale and shift).

Layer Normalization (LayerNorm)

Unlike BatchNorm, in Layer Normalization, the normalization is done within each individual example, across its feature dimensions. So we don’t look at any other examples in the batch.

For Transformer with and processing a sequence , and each token being represented as we compute mean and variance:

and then transform each feature as:

to not restrict the features with same normalized statistics, we use two learned vectors:

and the final output is:

Implementation:

class LayerNormalization(nn.Module):
    
    def __init__(self,d_model, ep: float = 10**-6):
        super().__init__()
        self.ep = ep ## to avoid division by zero
        self.alpha = nn.Parameter(torch.ones(d_model)) # parameter to multiply
        self.bias = nn.Parameter(torch.zeros(d_model)) # parameter to add
        
    def forward(self,x):
        mean = x.mean(dim=-1,keepdim=True) # mean of embedding dimension + keepdim to preserve dimension 
        var = ((x-mean)**2).mean(dim=-1,keepdim=True)
        x_hat = (x-mean)/torch.sqrt(var+self.ep)
        return self.bias + self.alpha*x_hat # layer normalization

Root Mean Square Normalization (RMSNorm)

RMSNorm is a simplified normalization that removes the mean-centering operation from LayerNorm. Instead of normalizing by the variance around the mean, it normalizes the input by its root mean square.

Implementation:

class RMSNormalization(nn.Module):
 
    def __init__(self, dim: int, eps: float = 1e-6):
        super().__init__()
        self.eps = eps
        self.weight = nn.Parameter(torch.ones(dim))
 
    def forward(self, x):
        rms = torch.sqrt(torch.mean(x.float() ** 2, dim=-1, keepdim=True) + self.eps)
        x_hat = x / rms.to(x.dtype)
        return self.weight * x_hat

Pre vs Post Norm

Pre_Post_Norm (a) Post-LN Transformer Layer; (b) Pre-LN Transformer Layer 1

β€œThe two main variants of residual connections, Pre-Norm and Post-Norm, each make distinct trade-offs between gradient vanishing and representation collapse. Pre-Norm applies normalization operations to the input before each residual block, effectively addressing the problem of gradient vanishing. However, it can also lead to the issue of collapse in deep representations, where hidden features in deeper layers become highly similar, diminishing the contribution of additional layers as their number increases. In contrast, Post-Norm applies normalization after the output of each residual block, reducing the influence of a hidden state on subsequent layers. This approach can alleviate the issue of representation collapse but also reintroduces the problem of vanishing gradients. The vanishing gradient and the representation collapse are like two ends of a seesaw, with these two variants making respective trade-offs between these issues. The key issue is that residual connections, including both Pre-Norm and Post-Norm variants, predefine the strength of connections between the output and input within a layer.” 2

Implementation:

class ResidualConnection(nn.Module):
    
    def __init__(self, d_model: int, dropout: float):
        super().__init__()
        self.dropout = nn.Dropout(dropout)
        self.norm = Normalization(d_model)
    
    def forward(self, x, sublayer):
        return x + self.dropout(sublayer(self.norm(x))) ## Pre-Norm
        return self.norm(self.dropout(sublayer(x))+x) ## Post-Norm

Footnotes

  1. On Layer Normalization in the Transformer Architecture ↩

  2. Hyper Connections ↩