Skip to content

Batch Normalization and Dropout Implementation

Essential regularization techniques for deep learning

Overview

Batch Normalization and Dropout are two fundamental techniques that have revolutionized deep learning training. BatchNorm addresses internal covariate shift by normalizing activations, while Dropout provides regularization by randomly dropping units during training.

Internal Covariate Shift - The problem BatchNorm solves

1. Batch Normalization from Scratch

Core Implementation

python
import numpy as np
from typing import Tuple, Optional, Dict

class BatchNormalization:
    """
    Batch Normalization layer implementation.

    Normalizes activations using batch statistics during training
    and running statistics during inference.

    Formula:
        x_hat = (x - mean) / sqrt(var + eps)
        y = gamma * x_hat + beta

    Parameters:
        num_features: Number of features/channels
        eps: Small constant for numerical stability
        momentum: Momentum for running mean/var updates
    """

    def __init__(
        self,
        num_features: int,
        eps: float = 1e-5,
        momentum: float = 0.1,
        affine: bool = True,
        track_running_stats: bool = True
    ):
        self.num_features = num_features
        self.eps = eps
        self.momentum = momentum
        self.affine = affine
        self.track_running_stats = track_running_stats

        # Learnable parameters (scale and shift)
        if self.affine:
            self.gamma = np.ones(num_features)  # Scale parameter
            self.beta = np.zeros(num_features)  # Shift parameter

            # Gradients for learnable parameters
            self.dgamma = np.zeros(num_features)
            self.dbeta = np.zeros(num_features)

        # Running statistics for inference
        if self.track_running_stats:
            self.running_mean = np.zeros(num_features)
            self.running_var = np.ones(num_features)
            self.num_batches_tracked = 0

        # Cache for backward pass
        self.cache = {}

        # Training mode flag
        self.training = True

    def train(self, mode: bool = True):
        """Set training mode."""
        self.training = mode
        return self

    def eval(self):
        """Set evaluation mode."""
        return self.train(False)

    def forward(self, x: np.ndarray) -> np.ndarray:
        """
        Forward pass for batch normalization.

        Args:
            x: Input tensor of shape (N, D) or (N, C, H, W)

        Returns:
            Normalized output of same shape as input
        """
        # Handle different input dimensions
        original_shape = x.shape

        if x.ndim == 4:
            # Conv layer: (N, C, H, W) -> normalize over N, H, W
            return self._forward_conv(x)
        else:
            # FC layer: (N, D) -> normalize over N
            return self._forward_fc(x)

    def _forward_fc(self, x: np.ndarray) -> np.ndarray:
        """Forward pass for fully connected layers."""
        N, D = x.shape

        if self.training:
            # Compute batch statistics
            batch_mean = np.mean(x, axis=0)
            batch_var = np.var(x, axis=0)

            # Normalize
            x_centered = x - batch_mean
            std = np.sqrt(batch_var + self.eps)
            x_norm = x_centered / std

            # Scale and shift
            if self.affine:
                out = self.gamma * x_norm + self.beta
            else:
                out = x_norm

            # Update running statistics
            if self.track_running_stats:
                self.running_mean = (1 - self.momentum) * self.running_mean + \
                                   self.momentum * batch_mean
                self.running_var = (1 - self.momentum) * self.running_var + \
                                  self.momentum * batch_var
                self.num_batches_tracked += 1

            # Cache values for backward pass
            self.cache = {
                'x': x,
                'x_norm': x_norm,
                'x_centered': x_centered,
                'std': std,
                'batch_mean': batch_mean,
                'batch_var': batch_var
            }

        else:
            # Use running statistics for inference
            x_norm = (x - self.running_mean) / np.sqrt(self.running_var + self.eps)

            if self.affine:
                out = self.gamma * x_norm + self.beta
            else:
                out = x_norm

        return out

    def _forward_conv(self, x: np.ndarray) -> np.ndarray:
        """Forward pass for convolutional layers (BatchNorm2d)."""
        N, C, H, W = x.shape

        if self.training:
            # Compute statistics over N, H, W (per channel)
            batch_mean = np.mean(x, axis=(0, 2, 3), keepdims=True)
            batch_var = np.var(x, axis=(0, 2, 3), keepdims=True)

            # Normalize
            x_centered = x - batch_mean
            std = np.sqrt(batch_var + self.eps)
            x_norm = x_centered / std

            # Scale and shift (gamma, beta have shape (C,))
            if self.affine:
                gamma_reshaped = self.gamma.reshape(1, C, 1, 1)
                beta_reshaped = self.beta.reshape(1, C, 1, 1)
                out = gamma_reshaped * x_norm + beta_reshaped
            else:
                out = x_norm

            # Update running statistics
            if self.track_running_stats:
                self.running_mean = (1 - self.momentum) * self.running_mean + \
                                   self.momentum * batch_mean.squeeze()
                self.running_var = (1 - self.momentum) * self.running_var + \
                                  self.momentum * batch_var.squeeze()
                self.num_batches_tracked += 1

            # Cache for backward
            self.cache = {
                'x': x,
                'x_norm': x_norm,
                'x_centered': x_centered,
                'std': std,
                'batch_mean': batch_mean,
                'batch_var': batch_var
            }

        else:
            # Inference mode
            mean_reshaped = self.running_mean.reshape(1, C, 1, 1)
            var_reshaped = self.running_var.reshape(1, C, 1, 1)

            x_norm = (x - mean_reshaped) / np.sqrt(var_reshaped + self.eps)

            if self.affine:
                gamma_reshaped = self.gamma.reshape(1, C, 1, 1)
                beta_reshaped = self.beta.reshape(1, C, 1, 1)
                out = gamma_reshaped * x_norm + beta_reshaped
            else:
                out = x_norm

        return out

    def backward(self, dout: np.ndarray) -> np.ndarray:
        """
        Backward pass for batch normalization.

        Args:
            dout: Gradient of loss w.r.t. output

        Returns:
            Gradient of loss w.r.t. input
        """
        if dout.ndim == 4:
            return self._backward_conv(dout)
        else:
            return self._backward_fc(dout)

    def _backward_fc(self, dout: np.ndarray) -> np.ndarray:
        """Backward pass for FC layers."""
        x = self.cache['x']
        x_norm = self.cache['x_norm']
        x_centered = self.cache['x_centered']
        std = self.cache['std']

        N, D = x.shape

        # Gradients for gamma and beta
        if self.affine:
            self.dbeta = np.sum(dout, axis=0)
            self.dgamma = np.sum(dout * x_norm, axis=0)

            # Gradient through scale
            dx_norm = dout * self.gamma
        else:
            dx_norm = dout

        # Gradient through normalization
        dvar = np.sum(dx_norm * x_centered * (-0.5) * (std ** -3), axis=0)
        dmean = np.sum(dx_norm * (-1 / std), axis=0) + \
                dvar * np.mean(-2 * x_centered, axis=0)

        dx = dx_norm / std + dvar * 2 * x_centered / N + dmean / N

        return dx

    def _backward_fc_optimized(self, dout: np.ndarray) -> np.ndarray:
        """Optimized backward pass using single formula."""
        x_norm = self.cache['x_norm']
        std = self.cache['std']

        N = dout.shape[0]

        if self.affine:
            self.dbeta = np.sum(dout, axis=0)
            self.dgamma = np.sum(dout * x_norm, axis=0)
            dx_norm = dout * self.gamma
        else:
            dx_norm = dout

        # Optimized formula
        dx = (1.0 / N) * (1.0 / std) * (
            N * dx_norm -
            np.sum(dx_norm, axis=0) -
            x_norm * np.sum(dx_norm * x_norm, axis=0)
        )

        return dx

    def _backward_conv(self, dout: np.ndarray) -> np.ndarray:
        """Backward pass for conv layers."""
        x = self.cache['x']
        x_norm = self.cache['x_norm']
        x_centered = self.cache['x_centered']
        std = self.cache['std']

        N, C, H, W = x.shape
        m = N * H * W  # Number of elements per channel

        if self.affine:
            # Gradients for gamma and beta
            self.dbeta = np.sum(dout, axis=(0, 2, 3))
            self.dgamma = np.sum(dout * x_norm, axis=(0, 2, 3))

            gamma_reshaped = self.gamma.reshape(1, C, 1, 1)
            dx_norm = dout * gamma_reshaped
        else:
            dx_norm = dout

        # Gradient through normalization (conv version)
        dvar = np.sum(dx_norm * x_centered * (-0.5) * (std ** -3),
                      axis=(0, 2, 3), keepdims=True)
        dmean = np.sum(dx_norm * (-1 / std), axis=(0, 2, 3), keepdims=True) + \
                dvar * np.mean(-2 * x_centered, axis=(0, 2, 3), keepdims=True)

        dx = dx_norm / std + dvar * 2 * x_centered / m + dmean / m

        return dx

    def get_params(self) -> Dict[str, np.ndarray]:
        """Get learnable parameters."""
        params = {}
        if self.affine:
            params['gamma'] = self.gamma
            params['beta'] = self.beta
        return params

    def get_gradients(self) -> Dict[str, np.ndarray]:
        """Get parameter gradients."""
        grads = {}
        if self.affine:
            grads['dgamma'] = self.dgamma
            grads['dbeta'] = self.dbeta
        return grads


class BatchNorm1d(BatchNormalization):
    """Batch normalization for 2D inputs (N, Features)."""
    pass


class BatchNorm2d(BatchNormalization):
    """Batch normalization for 4D inputs (N, C, H, W)."""
    pass

Running Statistics Evolution

Running Statistics Evolution during training

2. Dropout Implementation

Standard and Inverted Dropout

Dropout Mask Visualization

python
class Dropout:
    """
    Dropout layer implementation.

    During training, randomly zeroes elements with probability p.
    During inference, returns input unchanged (with inverted dropout)
    or scales by (1-p) (with standard dropout).

    Inverted Dropout (modern standard):
        - Scale activations by 1/(1-p) during training
        - No scaling needed during inference
    """

    def __init__(self, p: float = 0.5, inplace: bool = False):
        """
        Args:
            p: Probability of dropping a unit (0 to 1)
            inplace: If True, modify input in place
        """
        if not 0 <= p < 1:
            raise ValueError(f"Dropout probability must be in [0, 1), got {p}")

        self.p = p
        self.inplace = inplace
        self.training = True
        self.mask = None

    def train(self, mode: bool = True):
        """Set training mode."""
        self.training = mode
        return self

    def eval(self):
        """Set evaluation mode."""
        return self.train(False)

    def forward(self, x: np.ndarray) -> np.ndarray:
        """
        Forward pass with inverted dropout.

        Args:
            x: Input tensor of any shape

        Returns:
            Output tensor with dropout applied (training) or unchanged (inference)
        """
        if not self.training or self.p == 0:
            return x

        # Create binary mask
        self.mask = (np.random.rand(*x.shape) > self.p).astype(np.float64)

        # Apply mask and scale (inverted dropout)
        scale = 1.0 / (1.0 - self.p)

        if self.inplace:
            x *= self.mask * scale
            return x
        else:
            return x * self.mask * scale

    def backward(self, dout: np.ndarray) -> np.ndarray:
        """
        Backward pass for dropout.

        Gradient flows only through non-dropped units.
        """
        if not self.training or self.p == 0:
            return dout

        scale = 1.0 / (1.0 - self.p)
        return dout * self.mask * scale

    def forward_standard(self, x: np.ndarray) -> np.ndarray:
        """
        Forward pass with standard (non-inverted) dropout.

        Scaling happens at inference time instead.
        """
        if not self.training:
            # Scale at inference time
            return x * (1.0 - self.p)

        if self.p == 0:
            return x

        # Create mask without scaling during training
        self.mask = (np.random.rand(*x.shape) > self.p).astype(np.float64)
        return x * self.mask


class Dropout2d:
    """
    Spatial Dropout for convolutional layers.

    Drops entire channels instead of individual elements.
    More effective for conv layers where adjacent pixels are highly correlated.
    """

    def __init__(self, p: float = 0.5):
        self.p = p
        self.training = True
        self.mask = None

    def train(self, mode: bool = True):
        self.training = mode
        return self

    def eval(self):
        return self.train(False)

    def forward(self, x: np.ndarray) -> np.ndarray:
        """
        Forward pass - drops entire channels.

        Args:
            x: Input of shape (N, C, H, W)

        Returns:
            Output with entire channels dropped
        """
        if not self.training or self.p == 0:
            return x

        N, C, H, W = x.shape

        # Create channel-wise mask (N, C, 1, 1)
        self.mask = (np.random.rand(N, C, 1, 1) > self.p).astype(np.float64)

        # Broadcast mask across spatial dimensions
        scale = 1.0 / (1.0 - self.p)

        return x * self.mask * scale

    def backward(self, dout: np.ndarray) -> np.ndarray:
        """Backward pass for spatial dropout."""
        if not self.training or self.p == 0:
            return dout

        scale = 1.0 / (1.0 - self.p)
        return dout * self.mask * scale


class AlphaDropout:
    """
    Alpha Dropout for Self-Normalizing Neural Networks (SNNs).

    Maintains mean and variance of inputs, designed to work
    with SELU activation function.
    """

    def __init__(self, p: float = 0.5):
        self.p = p
        self.training = True

        # SELU parameters
        self.alpha = 1.6732632423543772848170429916717
        self.scale = 1.0507009873554804934193349852946

        # Derived constants for alpha dropout
        self.alpha_p = -self.alpha * self.scale

    def train(self, mode: bool = True):
        self.training = mode
        return self

    def forward(self, x: np.ndarray) -> np.ndarray:
        """Forward pass maintaining self-normalizing property."""
        if not self.training or self.p == 0:
            return x

        mask = np.random.rand(*x.shape) > self.p

        # Calculate affine transformation parameters
        a = 1.0 / np.sqrt(self.p + (1 - self.p) * self.alpha_p ** 2)
        b = -a * (1 - self.p) * self.alpha_p

        # Apply dropout
        out = np.where(mask, x, self.alpha_p)

        # Apply affine transformation to maintain mean/variance
        return a * out + b

3. Normalization Techniques Comparison

Normalization Comparison - BatchNorm vs LayerNorm vs GroupNorm

Layer Normalization Implementation

python
class LayerNormalization:
    """
    Layer Normalization implementation.

    Unlike BatchNorm, normalizes across features for each sample.
    Does not depend on batch size, making it suitable for:
    - RNNs/LSTMs/Transformers
    - Small batch sizes
    - Online learning
    """

    def __init__(
        self,
        normalized_shape: Tuple[int, ...],
        eps: float = 1e-5,
        elementwise_affine: bool = True
    ):
        if isinstance(normalized_shape, int):
            normalized_shape = (normalized_shape,)

        self.normalized_shape = normalized_shape
        self.eps = eps
        self.elementwise_affine = elementwise_affine

        if self.elementwise_affine:
            self.gamma = np.ones(normalized_shape)
            self.beta = np.zeros(normalized_shape)
            self.dgamma = np.zeros(normalized_shape)
            self.dbeta = np.zeros(normalized_shape)

        self.cache = {}
        self.training = True

    def forward(self, x: np.ndarray) -> np.ndarray:
        """Normalizes over the last len(normalized_shape) dimensions."""
        ndim = len(self.normalized_shape)
        axes = tuple(range(-ndim, 0))

        # Compute statistics per sample
        mean = np.mean(x, axis=axes, keepdims=True)
        var = np.var(x, axis=axes, keepdims=True)

        # Normalize
        x_centered = x - mean
        std = np.sqrt(var + self.eps)
        x_norm = x_centered / std

        # Scale and shift
        if self.elementwise_affine:
            out = self.gamma * x_norm + self.beta
        else:
            out = x_norm

        # Cache for backward
        self.cache = {
            'x_norm': x_norm,
            'x_centered': x_centered,
            'std': std,
            'axes': axes
        }

        return out

    def backward(self, dout: np.ndarray) -> np.ndarray:
        """Backward pass for layer normalization."""
        x_norm = self.cache['x_norm']
        x_centered = self.cache['x_centered']
        std = self.cache['std']
        axes = self.cache['axes']

        n = np.prod([x_norm.shape[i] for i in range(-len(self.normalized_shape), 0)])

        if self.elementwise_affine:
            batch_axes = tuple(range(len(dout.shape) - len(self.normalized_shape)))
            self.dbeta = np.sum(dout, axis=batch_axes)
            self.dgamma = np.sum(dout * x_norm, axis=batch_axes)
            dx_norm = dout * self.gamma
        else:
            dx_norm = dout

        dvar = np.sum(dx_norm * x_centered * (-0.5) * (std ** -3),
                      axis=axes, keepdims=True)
        dmean = np.sum(dx_norm * (-1 / std), axis=axes, keepdims=True) + \
                dvar * np.mean(-2 * x_centered, axis=axes, keepdims=True)

        dx = dx_norm / std + dvar * 2 * x_centered / n + dmean / n

        return dx


class RMSNorm:
    """
    Root Mean Square Layer Normalization.

    Simplified LayerNorm without centering (no mean subtraction).
    Used in modern architectures like LLaMA.

    Formula: y = x / RMS(x) * gamma
    where RMS(x) = sqrt(mean(x^2) + eps)
    """

    def __init__(self, normalized_shape: int, eps: float = 1e-6):
        self.normalized_shape = normalized_shape
        self.eps = eps
        self.gamma = np.ones(normalized_shape)
        self.dgamma = np.zeros(normalized_shape)
        self.cache = {}

    def forward(self, x: np.ndarray) -> np.ndarray:
        """Forward pass for RMS normalization."""
        rms = np.sqrt(np.mean(x ** 2, axis=-1, keepdims=True) + self.eps)
        x_norm = x / rms
        out = self.gamma * x_norm

        self.cache = {'x': x, 'x_norm': x_norm, 'rms': rms}
        return out

    def backward(self, dout: np.ndarray) -> np.ndarray:
        """Backward pass for RMS normalization."""
        x = self.cache['x']
        x_norm = self.cache['x_norm']
        rms = self.cache['rms']

        batch_axes = tuple(range(len(dout.shape) - 1))
        self.dgamma = np.sum(dout * x_norm, axis=batch_axes)

        dx_norm = dout * self.gamma
        dx = dx_norm / rms - x_norm * np.mean(dx_norm * x_norm, axis=-1, keepdims=True)

        return dx

4. Training vs Inference Mode

Training vs Inference Mode comparison

Complete Neural Network with BatchNorm and Dropout

python
class NeuralNetworkWithRegularization:
    """
    Multi-layer neural network with BatchNorm and Dropout.

    Architecture: FC -> BN -> ReLU -> Dropout -> ... -> Output
    """

    def __init__(
        self,
        layer_dims: list,
        dropout_rate: float = 0.5,
        use_batchnorm: bool = True,
        activation: str = 'relu'
    ):
        self.layer_dims = layer_dims
        self.num_layers = len(layer_dims) - 1
        self.dropout_rate = dropout_rate
        self.use_batchnorm = use_batchnorm
        self.activation = activation

        # Initialize weights and biases
        self.params = {}
        self.bn_layers = {}
        self.dropout_layers = {}

        for l in range(1, self.num_layers + 1):
            # Xavier/He initialization
            if activation == 'relu':
                scale = np.sqrt(2.0 / layer_dims[l-1])  # He initialization
            else:
                scale = np.sqrt(1.0 / layer_dims[l-1])  # Xavier initialization

            self.params[f'W{l}'] = np.random.randn(
                layer_dims[l-1], layer_dims[l]
            ) * scale
            self.params[f'b{l}'] = np.zeros(layer_dims[l])

            # BatchNorm for all except output layer
            if use_batchnorm and l < self.num_layers:
                self.bn_layers[l] = BatchNormalization(layer_dims[l])

            # Dropout for all except output layer
            if dropout_rate > 0 and l < self.num_layers:
                self.dropout_layers[l] = Dropout(dropout_rate)

        self.cache = {}
        self.training = True

    def train(self, mode: bool = True):
        """Set training mode for all layers."""
        self.training = mode
        for bn in self.bn_layers.values():
            bn.train(mode)
        for dropout in self.dropout_layers.values():
            dropout.train(mode)
        return self

    def eval(self):
        """Set evaluation mode."""
        return self.train(False)

    def _activation_forward(self, z: np.ndarray) -> np.ndarray:
        """Apply activation function."""
        if self.activation == 'relu':
            return np.maximum(0, z)
        elif self.activation == 'tanh':
            return np.tanh(z)
        elif self.activation == 'sigmoid':
            return 1 / (1 + np.exp(-np.clip(z, -500, 500)))
        else:
            raise ValueError(f"Unknown activation: {self.activation}")

    def _activation_backward(self, da: np.ndarray, z: np.ndarray) -> np.ndarray:
        """Backward pass through activation."""
        if self.activation == 'relu':
            return da * (z > 0)
        elif self.activation == 'tanh':
            return da * (1 - np.tanh(z) ** 2)
        elif self.activation == 'sigmoid':
            s = 1 / (1 + np.exp(-np.clip(z, -500, 500)))
            return da * s * (1 - s)
        else:
            raise ValueError(f"Unknown activation: {self.activation}")

    def forward(self, X: np.ndarray) -> np.ndarray:
        """Forward pass through the network."""
        self.cache['A0'] = X
        A = X

        for l in range(1, self.num_layers + 1):
            # Linear transformation
            W = self.params[f'W{l}']
            b = self.params[f'b{l}']
            Z = A @ W + b
            self.cache[f'Z{l}'] = Z

            if l < self.num_layers:
                # Hidden layer: BN -> Activation -> Dropout
                if self.use_batchnorm:
                    Z = self.bn_layers[l].forward(Z)
                    self.cache[f'Z_bn{l}'] = Z

                A = self._activation_forward(Z)
                self.cache[f'A{l}'] = A

                if self.dropout_rate > 0:
                    A = self.dropout_layers[l].forward(A)
                    self.cache[f'A_drop{l}'] = A
            else:
                # Output layer (no BN, activation, or dropout)
                A = Z

        return A

    def backward(self, dout: np.ndarray) -> Dict[str, np.ndarray]:
        """Backward pass through the network."""
        grads = {}
        N = dout.shape[0]

        dA = dout

        for l in range(self.num_layers, 0, -1):
            if l < self.num_layers:
                # Dropout backward
                if self.dropout_rate > 0:
                    dA = self.dropout_layers[l].backward(dA)

                # Activation backward
                Z = self.cache[f'Z_bn{l}'] if self.use_batchnorm else self.cache[f'Z{l}']
                dZ = self._activation_backward(dA, Z)

                # BatchNorm backward
                if self.use_batchnorm:
                    dZ = self.bn_layers[l].backward(dZ)
            else:
                dZ = dA

            # Linear backward
            A_prev = self.cache[f'A{l-1}']
            W = self.params[f'W{l}']

            grads[f'dW{l}'] = A_prev.T @ dZ / N
            grads[f'db{l}'] = np.mean(dZ, axis=0)

            if l > 1:
                dA = dZ @ W.T

        # Add BatchNorm gradients
        if self.use_batchnorm:
            for l, bn in self.bn_layers.items():
                bn_grads = bn.get_gradients()
                grads[f'dgamma{l}'] = bn_grads['dgamma']
                grads[f'dbeta{l}'] = bn_grads['dbeta']

        return grads

    def update_params(self, grads: Dict[str, np.ndarray], lr: float = 0.01):
        """Update parameters using gradients."""
        for l in range(1, self.num_layers + 1):
            self.params[f'W{l}'] -= lr * grads[f'dW{l}']
            self.params[f'b{l}'] -= lr * grads[f'db{l}']

            if self.use_batchnorm and l < self.num_layers:
                self.bn_layers[l].gamma -= lr * grads[f'dgamma{l}']
                self.bn_layers[l].beta -= lr * grads[f'dbeta{l}']

5. Testing and Verification

python
def test_batch_normalization():
    """Test BatchNorm implementation against expected behavior."""
    print("Testing Batch Normalization...")

    np.random.seed(42)

    # Test 1: Forward pass (training mode)
    bn = BatchNormalization(num_features=4)
    x = np.random.randn(32, 4)

    out = bn.forward(x)

    # Check that output has approximately zero mean and unit variance
    mean = np.mean(out, axis=0)
    var = np.var(out, axis=0)

    print(f"Output mean (should be ~0): {mean}")
    print(f"Output variance (should be ~1): {var}")

    assert np.allclose(mean, 0, atol=1e-5), "Mean should be close to 0"
    assert np.allclose(var, 1, atol=1e-2), "Variance should be close to 1"

    # Test 2: Backward pass (gradient check)
    dout = np.random.randn(*out.shape)
    dx = bn.backward(dout)

    # Test 3: Inference mode uses running statistics
    bn.eval()
    x_test = np.random.randn(16, 4)
    out_eval = bn.forward(x_test)

    print(f"Running mean: {bn.running_mean}")
    print(f"Running var: {bn.running_var}")

    print("BatchNorm tests passed!")


def test_dropout():
    """Test Dropout implementation."""
    print("\nTesting Dropout...")

    np.random.seed(42)

    # Test 1: Training mode
    dropout = Dropout(p=0.5)
    x = np.ones((1000, 100))

    out = dropout.forward(x)

    # About 50% should be zero
    zero_fraction = np.mean(out == 0)
    print(f"Fraction of zeros (should be ~0.5): {zero_fraction}")

    # Non-zero values should be scaled by 2 (inverted dropout)
    non_zero_values = out[out != 0]
    print(f"Non-zero values (should be 2.0): {np.mean(non_zero_values)}")

    # Test 2: Inference mode
    dropout.eval()
    out_eval = dropout.forward(x)

    # Should be unchanged in eval mode (inverted dropout)
    assert np.allclose(out_eval, x), "Eval mode should return input unchanged"
    print("Eval mode correctly returns input unchanged")

    print("Dropout tests passed!")


def test_layer_normalization():
    """Test LayerNorm implementation."""
    print("\nTesting Layer Normalization...")

    np.random.seed(42)

    ln = LayerNormalization(normalized_shape=(64,))
    x = np.random.randn(32, 64)

    out = ln.forward(x)

    # Check per-sample statistics
    mean = np.mean(out, axis=1)
    var = np.var(out, axis=1)

    print(f"Per-sample means (should be ~0): max={np.max(np.abs(mean))}")
    print(f"Per-sample variances (should be ~1): mean={np.mean(var)}")

    assert np.allclose(mean, 0, atol=1e-5), "Means should be close to 0"
    assert np.allclose(var, 1, atol=1e-2), "Variances should be close to 1"

    print("LayerNorm tests passed!")


if __name__ == "__main__":
    test_batch_normalization()
    test_dropout()
    test_layer_normalization()
    print("\n All tests passed!")

6. When to Use Each Technique

Batch Normalization

Use CaseRecommendation
Deep CNNsHighly recommended
Large batch sizes (32+)Excellent choice
Training speedSignificantly faster convergence
Higher learning ratesEnables larger LRs
Small batch sizes (<8)Consider LayerNorm or GroupNorm
RNNs/TransformersUse LayerNorm instead

Dropout

Use CaseRecommendation
Fully connected layersStandard approach (p=0.5)
Convolutional layersLower rate (p=0.1-0.3) or Dropout2d
After BatchNormApply after activation (BN -> activation -> Dropout)
Modern architecturesOften replaced by other regularization
Small datasetsEssential for preventing overfitting
Large modelsCritical regularization technique

Layer Normalization

Use CaseRecommendation
TransformersStandard choice
RNNs/LSTMsWorks better than BatchNorm
Small batch sizesIndependent of batch
Online learningNo running statistics needed
Variable sequence lengthsNatural handling

7. Key Insights Summary

+-------------------+------------------+-------------------+
|     Property      |    BatchNorm     |     LayerNorm     |
+-------------------+------------------+-------------------+
| Normalizes over   | Batch dimension  | Feature dimension |
| Running stats     | Yes              | No                |
| Batch dependency  | Yes              | No                |
| Best for          | CNNs             | Transformers/RNNs |
| Training/Eval diff| Yes              | No                |
+-------------------+------------------+-------------------+

Dropout Key Points:
- Inverted dropout: scale during training, not inference
- Creates ensemble effect (exponentially many subnetworks)
- Don't use with BatchNorm in same position (controversial)
- Spatial dropout for conv layers
- Alpha dropout for self-normalizing networks

8. Common Pitfalls and Solutions

python
"""
Common issues and how to avoid them:

1. BatchNorm with small batches:
   - Problem: Noisy batch statistics
   - Solution: Use GroupNorm or LayerNorm
   - Or: Increase batch size with gradient accumulation

2. Dropout at test time:
   - Problem: Forgetting to switch to eval mode
   - Solution: Always call model.eval() before inference

3. BatchNorm momentum confusion:
   - Problem: PyTorch uses 0.1 default, others use 0.9
   - Note: PyTorch momentum = 1 - TensorFlow momentum

4. Order of operations:
   - Recommended: Conv -> BN -> Activation -> Dropout
   - Don't apply dropout before BatchNorm

5. Dropout and determinism:
   - Problem: Different results each forward pass
   - Solution: Set random seed for reproducibility

6. BatchNorm with varying batch sizes:
   - Problem: First batch sets running stats scale
   - Solution: Warm up with representative batches
"""