Concept: Manual Layer

Core Intuition

A Linear Layer (or Fully Connected/Dense layer) performs an affine transformation on input data. It is the fundamental building block of neural networks, mapping an input vector to an output vector through weight multiplication and bias addition.

Mathematical Foundation

For a layer with input , weights , and bias :

Forward Pass

The output is calculated as:

Backward Pass (Derivation)

Given the gradient of the loss with respect to the output , we need to calculate:

  1. Gradient w.r.t. Weights (): To find , consider the element (weight connecting input to output ). From , an element of the output is: . The partial derivative of a single output with respect to is: Using the chain rule, we sum the contributions of to all outputs it affected (which are only the -th column of ): This summation corresponds to the dot product between the -th column of and the -th column of . In matrix form, this is: Resulting shape: .

  2. Gradient w.r.t. Bias (): The bias is added to every sample in the batch for the -th output dimension (). Thus, . By the chain rule: In vector form, the gradient for the bias vector is the sum of gradients across the batch: Resulting shape: .

  3. Gradient w.r.t. Input (): Using the chain rule: Since , then . Resulting shape: .

Key Equation

Intuitive Gradient Rules

For quick derivation of , use these mental shortcuts:

  • Dimension Matching: If is and you need as , the only valid matrix product is .
  • The “Mirror” Rule: To find the gradient of a term, you multiply the incoming gradient () by the other term in the product, transposed.
    • For : multiply by (adjusted for the layout in ).
    • For : multiply by .
  • Batch Pooling (for Bias): Since the bias is “shared” (added to every single sample), its gradient must aggregate the error signals from the entire batch. Summing across the batch is the natural way to “pool” this shared influence.

Analogy

Think of a linear layer as a projection screen. The input is the object, the weights are the angle and properties of the lens that project it into a new space (dimension), and the bias is the translation (moving the projection on the screen).

Component of

Insights

  • Affine Transformation: It’s linear transformation + translation.
  • Dimensionality Change: Used to expand or compress the feature space.
  • Weight Shape: In PyTorch, weights are stored as (out_features, in_features) to optimize the matrix multiplication as .

Pitfalls

  • Vanishing Gradients: Without non-linear activations, stacking linear layers is equivalent to a single linear layer.
  • Initialization: Poor initialization (e.g., all zeros) leads to broken symmetry where all neurons learn the same features.

Connections

Implementation Notes

import numpy as np
from typing import Tuple, Dict, Any
 
class Linear:
	"""
	Linear Layer with manual backward pass implementation.
	
	Architecture Note: Stateless/Functional Pattern
	----------------------------------------------
	This implementation follows a stateless pattern where the layer does not 
	internally store the forward pass data (X). Instead, it returns a 'cache'.
	
	Why this pattern?
	1. Thread-Safety: Allows the same layer instance to be used in parallel.
	2. Weight Sharing: The same layer can be called multiple times in a single 
	   computational graph without overwriting internal state.
	3. Explicit Backprop: Clearly demonstrates exactly which tensors from the 
	   forward pass are required to compute gradients.
	
	Who 'catches' the cache? 
	In a full framework, a 'Model' or 'Sequential' container stores these 
	caches in a list during forward() and provides them back to the layers 
	in reverse order during backward().
	"""
 
    def __init__(self, in_features: int, out_features: int):
        # He initialization for ReLU networks
        self.W = np.random.randn(out_features, in_features) * np.sqrt(2.0 / in_features)
        self.b = np.zeros((1, out_features))
 
        # Gradients stored after backward()
        self.dW = None
        self.db = None
 
    def forward(self, X: np.ndarray) -> Tuple[np.ndarray, Dict[str, np.ndarray]]:
        """
        Z = XW^T + b
        X shape: (batch_size, in_features)
        W shape: (out_features, in_features)
        b shape: (1, out_features)
        """
        Z = np.dot(X, self.W.T) + self.b
        cache = {"X": X}
        return Z, cache
 
    def backward(self, dZ: np.ndarray, cache: Dict[str, np.ndarray]) -> np.ndarray:
        """
        dZ shape: (batch_size, out_features)
        """
        X = cache["X"]
        batch_size = X.shape[0]
 
        # 1. Gradient wrt Weights: (out, batch) @ (batch, in) -> (out, in)
        self.dW = np.dot(dZ.T, X)
 
        # 2. Gradient wrt Bias: sum across batch
        self.db = np.sum(dZ, axis=0, keepdims=True)
 
        # 3. Gradient wrt Input (to pass to previous layer): (batch, out) @ (out, in) -> (batch, in)
        dX = np.dot(dZ, self.W)
 
        return dX