📄 transformer.py
/home/palash/git/iron_learn/python_scripts/transformer/transformer.py
Language: py • Lines: 371
import cupy as np
from simple_layers import Linear, ReLU, Softmax

try:
    np.cuda.Device(0).use()
    print("GPU found and will be used for training.")
except Exception as e:
    print(f"GPU not found or error: {e}")

class Embedding:
    def __init__(self, vocab_size, d_model):
        self.vocab_size = vocab_size
        self.d_model = d_model
        self.weight = np.random.randn(vocab_size, d_model) * np.sqrt(2.0 / vocab_size)

    def forward(self, x):
        assert x.ndim == 1, "Input should be a 1D array of token indices"
        self.inputs = x
        return self.weight[x]
    
    def backward(self, grad_output, learning_rate):
        np.add.at(self.weight, self.inputs, -learning_rate * grad_output)
        return None

class PositionalEncoding:
    def __init__(self, embedding_dimension, sequence_length):
        self.embedding_dimension = embedding_dimension
        self.sequence_length = sequence_length
        self.weight = np.random.randn(self.sequence_length, self.embedding_dimension) * 0.02
    
    def forward(self, x):
        assert x.ndim == 2 and x.shape[1] == self.embedding_dimension, "Input should be of shape (sequence_length, embedding_dimension)"
        self.inputs = x
        return x + self.weight[:x.shape[0], :]
    
    def backward(self, grad_output, learning_rate):
        # Correctly update positional encoding weights
        seq_len = grad_output.shape[0]
        self.weight[:seq_len] -= learning_rate * grad_output
        return grad_output
class Dropout:
    def __init__(self, p=0.1):
        self.p = p
        self.mask = None

    def forward(self, x, training=True):
        if not training or self.p == 0:
            return x
        # Create a mask of 1s and 0s. 
        # (1-p) is the probability of keeping the neuron active.
        self.mask = (np.random.rand(*x.shape) > self.p)
        # Scale the output by 1/(1-p) so the expected value remains the same
        return (x * self.mask) / (1.0 - self.p)

    def backward(self, grad_output):
        if self.mask is None:
            return grad_output
        return (grad_output * self.mask) / (1.0 - self.p)
    
class Attention:
    def __init__(self, d_model, num_heads):
        assert d_model % num_heads == 0, "d_model must be divisible by num_heads"
        self.d_model = d_model
        self.num_heads = num_heads
        self.d_k = d_model // num_heads
        
        self.W_q = Linear(d_model, d_model)
        self.W_k = Linear(d_model, d_model)
        self.W_v = Linear(d_model, d_model)
        self.W_o = Linear(d_model, d_model)
        self.softmax_layers = [Softmax() for _ in range(num_heads)]

        self.q_full = None
        self.k_full = None
        self.v_full = None
        self.attention_probs = {}
        self.inputs = None
        self.causal_mask = None
        
    
    def forward(self, x):
        assert x.ndim == 2 and x.shape[1] == self.d_model, "Input should be of shape (sequence_length, d_model)"
        self.inputs = x

        self.q_full = self.W_q.forward(x)
        self.k_full = self.W_k.forward(x)
        self.v_full = self.W_v.forward(x)

        self.causal_mask = np.triu(np.ones((x.shape[0], x.shape[0])) * -1e9, k=1)

        head_outputs = []

        for i in range(self.num_heads):
            start, end = i * self.d_k, (i + 1) * self.d_k
            q = self.q_full[:, start:end]
            k = self.k_full[:, start:end]
            v = self.v_full[:, start:end]

            attention_scores = np.dot(q, k.T) / np.sqrt(self.d_k)
            attention_scores += self.causal_mask
            self.attention_probs[i] = self.softmax_layers[i].forward(attention_scores)

            head_output = np.dot(self.attention_probs[i], v)

            head_outputs.append(head_output)

        attention_output = np.concatenate(head_outputs, axis=-1)
        return self.W_o.forward(attention_output)
    
    def backward(self, grad_output, learning_rate):
        grad_attention_output = self.W_o.backward(grad_output, learning_rate)

        grad_q_full = np.zeros_like(self.q_full)
        grad_k_full = np.zeros_like(self.k_full)
        grad_v_full = np.zeros_like(self.v_full)

        for i in range(self.num_heads):
            start, end = i * self.d_k, (i + 1) * self.d_k
            grad_head_output = grad_attention_output[:, start:end]

            q, k, v = self.q_full[:, start:end], self.k_full[:, start:end], self.v_full[:, start:end]
            probs = self.attention_probs[i]

            # Gradient w.r.t. values
            grad_v_full[:, start:end] = np.dot(probs.T, grad_head_output)
            # Gradient w.r.t. attention probabilities
            grad_probs = np.dot(grad_head_output, v.T)

            # Gradient w.r.t. attention scores (before softmax, after mask)
            grad_scores = self.softmax_layers[i].backward(grad_probs) / np.sqrt(self.d_k)
            # Apply mask: gradients at masked positions should be zero
            grad_scores = grad_scores * (self.causal_mask == 0)

            # Gradient w.r.t. queries and keys
            grad_q_full[:, start:end] = np.dot(grad_scores, k)
            grad_k_full[:, start:end] = np.dot(grad_scores.T, q)

        grad_input_q = self.W_q.backward(grad_q_full, learning_rate)
        grad_input_k = self.W_k.backward(grad_k_full, learning_rate)
        grad_input_v = self.W_v.backward(grad_v_full, learning_rate)

        return grad_input_q + grad_input_k + grad_input_v
    
class Attention_ND:
    def __init__(self, d_model, num_heads):
        assert d_model % num_heads == 0
        self.d_model = d_model
        self.num_heads = num_heads
        self.d_k = d_model // num_heads
        
        self.W_q = Linear(d_model, d_model)
        self.W_k = Linear(d_model, d_model)
        self.W_v = Linear(d_model, d_model)
        self.W_o = Linear(d_model, d_model)
        self.softmax = Softmax() # One softmax for the whole tensor

    def forward(self, x):
        self.inputs = x
        seq_len = x.shape[0]

        # 1. Linear projections
        q = self.W_q.forward(x) # (seq_len, d_model)
        k = self.W_k.forward(x)
        v = self.W_v.forward(x)

        # 2. Reshape for Multi-Head: (seq_len, heads, d_k) 
        # then transpose to (heads, seq_len, d_k)
        self.q = q.reshape(seq_len, self.num_heads, self.d_k).transpose(1, 0, 2)
        self.k = k.reshape(seq_len, self.num_heads, self.d_k).transpose(1, 0, 2)
        self.v = v.reshape(seq_len, self.num_heads, self.d_k).transpose(1, 0, 2)

        # 3. Scaled Dot-Product Attention (Parallel over heads)
        # scores: (heads, seq_len, seq_len)
        scores = np.matmul(self.q, self.k.transpose(0, 2, 1)) / np.sqrt(self.d_k)
        
        # Causal mask
        mask = np.triu(np.ones((seq_len, seq_len)) * -1e9, k=1)
        scores += mask

        self.probs = self.softmax.forward(scores)
        
        # 4. Concatenate heads
        # out: (heads, seq_len, d_k) -> (seq_len, heads, d_k) -> (seq_len, d_model)
        out = np.matmul(self.probs, self.v)
        out = out.transpose(1, 0, 2).reshape(seq_len, self.d_model)
        
        return self.W_o.forward(out)

    def backward(self, grad_output, learning_rate):
        # Backward through W_o
        grad_out = self.W_o.backward(grad_output, learning_rate)
        seq_len = grad_out.shape[0]

        # Reshape back to heads
        grad_out = grad_out.reshape(seq_len, self.num_heads, self.d_k).transpose(1, 0, 2)

        # Gradient w.r.t V
        grad_v = np.matmul(self.probs.transpose(0, 2, 1), grad_out)
        
        # Gradient w.r.t Probs
        grad_probs = np.matmul(grad_out, self.v.transpose(0, 2, 1))
        
        # Gradient w.r.t Scores
        grad_scores = self.softmax.backward(grad_probs) / np.sqrt(self.d_k)
        
        # Gradient w.r.t Q and K
        grad_q = np.matmul(grad_scores, self.k)
        grad_k = np.matmul(grad_scores.transpose(0, 2, 1), self.q)

        # Reshape and transpose back to (seq_len, d_model)
        grad_q = grad_q.transpose(1, 0, 2).reshape(seq_len, self.d_model)
        grad_k = grad_k.transpose(1, 0, 2).reshape(seq_len, self.d_model)
        grad_v = grad_v.transpose(1, 0, 2).reshape(seq_len, self.d_model)

        # Backward through initial Linear layers
        return (self.W_q.backward(grad_q, learning_rate) + 
                self.W_k.backward(grad_k, learning_rate) + 
                self.W_v.backward(grad_v, learning_rate))

class FeedForward:
    def __init__(self, d_model, d_ff):
        self.linear1 = Linear(d_model, d_ff)
        self.relu = ReLU()
        self.linear2 = Linear(d_ff, d_model)

        
    def forward(self, x):
        x = self.linear1.forward(x)
        x = self.relu.forward(x)
        x = self.linear2.forward(x)
        return x
    
    def backward(self, grad_output, learning_rate):
        grad_output = self.linear2.backward(grad_output, learning_rate)
        grad_output = self.relu.backward(grad_output)
        grad_output = self.linear1.backward(grad_output, learning_rate)
        return grad_output

class ResidualConnection:
    def __init__(self):
        pass

    def forward(self, x, sublayer_output):
        return x + sublayer_output
    
    def backward(self, grad_output):
        return grad_output, grad_output

class LayerNorm:
    def __init__(self, d_model, eps=1e-6):
        self.d_model = d_model
        self.eps = eps
        self.gamma = np.ones(d_model)
        self.beta = np.zeros(d_model)

    def forward(self, x):
        self.inputs = x
        self.mean = np.mean(x, axis=-1, keepdims=True)
        self.var = np.var(x, axis=-1, keepdims=True)
        self.x_normalized = (x - self.mean) / np.sqrt(self.var + self.eps)
        return self.gamma * self.x_normalized + self.beta
    
    def backward(self, grad_output, learning_rate):
        # Gradient w.r.t. scale (gamma) and shift (beta) parameters
        grad_gamma = np.sum(grad_output * self.x_normalized, axis=0)
        grad_beta = np.sum(grad_output, axis=0)

        # Gradient w.r.t. normalized input
        grad_x_normalized = grad_output * self.gamma

        # Gradient of normalization: chain rule through variance and mean
        N = self.d_model
        grad_var = np.sum(grad_x_normalized * (self.inputs - self.mean) * -0.5 * (self.var + self.eps) ** (-1.5), axis=-1, keepdims=True)
        grad_mean_from_var = grad_var * (-2.0 / N) * np.sum(self.inputs - self.mean, axis=-1, keepdims=True)
        grad_mean_direct = np.sum(grad_x_normalized * -1.0 / np.sqrt(self.var + self.eps), axis=-1, keepdims=True)
        grad_mean = grad_mean_direct + grad_mean_from_var

        # Gradient w.r.t. input
        grad_input = grad_x_normalized / np.sqrt(self.var + self.eps)
        grad_input += grad_var * 2.0 * (self.inputs - self.mean) / N
        grad_input += grad_mean / N

        # Update parameters
        self.gamma -= learning_rate * grad_gamma
        self.beta -= learning_rate * grad_beta

        return grad_input

class Transformer:
    def __init__(self, vocab_size, d_model, num_heads, d_ff, sequence_length, dropout_p=0.1):
        self.vocab_size = vocab_size
        self.d_model = d_model
        self.embedding = Embedding(vocab_size, d_model)
        self.positional_encoding = PositionalEncoding(d_model, sequence_length)
        self.emb_dropout = Dropout(dropout_p)
        self.attention = Attention_ND(d_model, num_heads)
        self.dropout1 = Dropout(dropout_p)
        self.dropout2 = Dropout(dropout_p)
        self.feed_forward = FeedForward(d_model, d_ff)
        self.residual1 = ResidualConnection()
        self.norm1 = LayerNorm(d_model)
        self.residual2 = ResidualConnection()
        self.norm2 = LayerNorm(d_model)
        # Output projection: d_model -> vocab_size
        self.output_projection = Linear(d_model, vocab_size)

    def forward(self, x, training=True):
        assert x.ndim == 1, "Input should be a 1D array of token indices"
        # Embedding + Positional Encoding
        x = self.embedding.forward(x)
        x = self.positional_encoding.forward(x)
        x = self.emb_dropout.forward(x, training=training)
        self.pos_encoded = x

        # Attention block with residual and normaliation
        attn_output = self.attention.forward(x)
        attn_output = self.dropout1.forward(attn_output, training=training)
        x = self.residual1.forward(x, attn_output)
        x = self.norm1.forward(x)
        self.after_norm1 = x

        # Feed-forward block with residual and normalization
        ff_output = self.feed_forward.forward(x)
        ff_output = self.dropout2.forward(ff_output, training=training)
        x = self.residual2.forward(x, ff_output)
        x = self.norm2.forward(x)
        self.after_norm2 = x

        # Output projection to vocab size
        logits = self.output_projection.forward(x)
        return logits
    
    def backward(self, grad_output, learning_rate):
        # Gradient through output projection
        grad_after_norm2 = self.output_projection.backward(grad_output, learning_rate)
        
        # Gradient through norm2
        grad_residual2_input = self.norm2.backward(grad_after_norm2, learning_rate)
        
        # Split gradient through residual2 connection
        grad_x_before_ff, grad_ff_output = self.residual2.backward(grad_residual2_input)

        grad_ff_output = self.dropout2.backward(grad_ff_output)
        
        # Backward through feed-forward block
        grad_after_norm1 = self.feed_forward.backward(grad_ff_output, learning_rate)
        
        # Combine gradients from residual connection and feed-forward
        grad_after_norm1 = grad_after_norm1 + grad_x_before_ff
        
        # Gradient through norm1
        grad_residual1_input = self.norm1.backward(grad_after_norm1, learning_rate)
        
        # Split gradient through residual1 connection
        grad_pos_encoded, grad_attention_output = self.residual1.backward(grad_residual1_input)

        grad_attention_output = self.dropout1.backward(grad_attention_output)
        
        # Backward through attention block
        grad_attention_input = self.attention.backward(grad_attention_output, learning_rate)
        
        # Combine gradients from residual connection and attention
        grad_attention_input = grad_attention_input + grad_pos_encoded
        
        # Backward through positional encoding
        grad_embedding_output = self.positional_encoding.backward(grad_attention_input, learning_rate)

        grad_embedding_output = self.emb_dropout.backward(grad_embedding_output)
        
        # Backward through embedding (no gradient returned, just weight updates)
        self.embedding.backward(grad_embedding_output, learning_rate)