Skip to content

Softmax and Cross-Entropy Implementation

Essential building blocks for neural network classification


Problem Statement

Implement the softmax activation function and cross-entropy loss from scratch. These are fundamental components in neural network classification tasks.

Requirements:

  1. Implement numerically stable softmax function
  2. Implement cross-entropy loss for multi-class classification
  3. Implement the combined softmax + cross-entropy for efficiency
  4. Compute gradients for backpropagation
  5. Handle edge cases (numerical overflow, zero probabilities)

Why This Matters:

  • Softmax converts logits to probabilities
  • Cross-entropy measures the difference between predicted and true distributions
  • Combined implementation is more numerically stable and computationally efficient
  • Understanding these is crucial for building and debugging neural networks

When This Is Asked

CompanyContextFrequency
GoogleML fundamentals, numerical stabilityVery High
MetaClassification systems, recommendationHigh
AmazonML infrastructure, SageMaker internalsHigh
OpenAIDeep learning foundationsVery High
AppleOn-device ML optimizationMedium
NetflixRecommendation systemsMedium

Interview Scenarios:

  • "Implement softmax without using any ML library functions"
  • "Why is naive softmax implementation problematic?"
  • "Derive the gradient of cross-entropy with softmax"
  • "How would you make this numerically stable?"

Mathematical Foundation

Softmax Function

The softmax function converts logits into a probability distribution:

softmax(z)_i = exp(z_i) / sum_j(exp(z_j))

Properties: Output in (0,1), sums to 1, preserves ordering, differentiable everywhere.

Temperature Scaling: Using softmax(z/T) controls output "sharpness":

Softmax Temperature Effect

Cross-Entropy Loss

Cross-entropy measures divergence between predicted and true distributions:

H(p, q) = -sum_i(p_i * log(q_i))
L = -log(q_y)  for one-hot labels (y is true class)

Probability Simplex and Loss Landscape

Why Cross-Entropy over MSE?

Cross-Entropy vs MSE Loss Comparison

Cross-entropy provides stronger gradients when predictions are wrong, enabling faster learning.

Gradient Computation

The combined softmax + cross-entropy gradient is elegantly simple:

dL/dz_i = s_i - y_i

Gradient Behavior at Different Confidence Levels

Log-Sum-Exp Trick

To prevent numerical overflow: log(sum(exp(z_i))) = max(z) + log(sum(exp(z_i - max(z))))


Implementation

Softmax (From Scratch)

python
import numpy as np
from typing import Union, Optional, Tuple
import warnings


class Softmax:
    """
    Softmax activation function with numerical stability.

    Converts logits to probabilities using the formula:
    softmax(z)_i = exp(z_i) / sum_j(exp(z_j))

    Uses the log-sum-exp trick for numerical stability.
    """

    def __init__(self, axis: int = -1):
        """
        Initialize Softmax.

        Args:
            axis: Axis along which to compute softmax (default: -1, last axis)
        """
        self.axis = axis
        self._input = None  # Cache for backward pass
        self._output = None

    def forward(self, logits: np.ndarray) -> np.ndarray:
        """
        Compute softmax of input logits.

        Args:
            logits: Input array of any shape

        Returns:
            Probability distribution with same shape as input
        """
        # Cache input for backward pass
        self._input = logits

        # Numerical stability: subtract max to prevent overflow
        # This doesn't change the result due to softmax properties
        shifted = logits - np.max(logits, axis=self.axis, keepdims=True)

        # Compute exponentials
        exp_shifted = np.exp(shifted)

        # Normalize to get probabilities
        self._output = exp_shifted / np.sum(exp_shifted, axis=self.axis, keepdims=True)

        return self._output

    def backward(self, grad_output: np.ndarray) -> np.ndarray:
        """
        Compute gradient of softmax.

        The Jacobian of softmax is:
        dS_i/dz_j = S_i * (delta_ij - S_j)

        Args:
            grad_output: Gradient from subsequent layer

        Returns:
            Gradient with respect to input logits
        """
        if self._output is None:
            raise RuntimeError("Must call forward before backward")

        s = self._output

        # For each sample, compute: grad_output @ Jacobian
        # Jacobian[i,j] = s[i] * (delta[i,j] - s[j])
        # This simplifies to: s * (grad_output - sum(grad_output * s))

        sum_grad_s = np.sum(grad_output * s, axis=self.axis, keepdims=True)
        grad_input = s * (grad_output - sum_grad_s)

        return grad_input

    def __call__(self, logits: np.ndarray) -> np.ndarray:
        """Allow using instance as a function."""
        return self.forward(logits)


def softmax_naive(logits: np.ndarray, axis: int = -1) -> np.ndarray:
    """
    Naive softmax implementation (NOT numerically stable).

    WARNING: This can overflow for large logits!
    Only use for educational purposes.

    Args:
        logits: Input logits
        axis: Axis for softmax computation

    Returns:
        Softmax probabilities
    """
    exp_logits = np.exp(logits)  # Can overflow!
    return exp_logits / np.sum(exp_logits, axis=axis, keepdims=True)


def softmax_stable(logits: np.ndarray, axis: int = -1) -> np.ndarray:
    """
    Numerically stable softmax function.

    Uses the max-subtraction trick to prevent overflow.

    Args:
        logits: Input logits of any shape
        axis: Axis along which to compute softmax

    Returns:
        Softmax probabilities with same shape as input
    """
    # Subtract max for numerical stability
    shifted = logits - np.max(logits, axis=axis, keepdims=True)
    exp_shifted = np.exp(shifted)
    return exp_shifted / np.sum(exp_shifted, axis=axis, keepdims=True)


def log_softmax(logits: np.ndarray, axis: int = -1) -> np.ndarray:
    """
    Compute log of softmax in a numerically stable way.

    log_softmax(z)_i = z_i - log(sum(exp(z_j)))
                     = z_i - max(z) - log(sum(exp(z_j - max(z))))

    Args:
        logits: Input logits
        axis: Axis for computation

    Returns:
        Log probabilities
    """
    max_logits = np.max(logits, axis=axis, keepdims=True)
    shifted = logits - max_logits
    log_sum_exp = np.log(np.sum(np.exp(shifted), axis=axis, keepdims=True))
    return shifted - log_sum_exp

Cross-Entropy Loss

python
class CrossEntropyLoss:
    """
    Cross-entropy loss for classification.

    Supports both:
    - Hard labels (class indices)
    - Soft labels (probability distributions)
    """

    def __init__(
        self,
        reduction: str = 'mean',
        label_smoothing: float = 0.0,
        ignore_index: int = -100,
        epsilon: float = 1e-12
    ):
        """
        Initialize CrossEntropyLoss.

        Args:
            reduction: 'none', 'mean', or 'sum'
            label_smoothing: Smoothing factor (0 = no smoothing)
            ignore_index: Index to ignore in loss computation
            epsilon: Small constant for numerical stability
        """
        self.reduction = reduction
        self.label_smoothing = label_smoothing
        self.ignore_index = ignore_index
        self.epsilon = epsilon

        # Cache for backward pass
        self._probs = None
        self._targets = None
        self._mask = None

    def forward(
        self,
        probs: np.ndarray,
        targets: np.ndarray
    ) -> Union[float, np.ndarray]:
        """
        Compute cross-entropy loss.

        Args:
            probs: Predicted probabilities, shape (N, C) or (N,)
            targets: Target labels (indices or one-hot), shape (N,) or (N, C)

        Returns:
            Loss value(s) depending on reduction
        """
        # Ensure 2D for batch processing
        if probs.ndim == 1:
            probs = probs.reshape(1, -1)

        batch_size, num_classes = probs.shape

        # Clip probabilities for numerical stability
        probs_clipped = np.clip(probs, self.epsilon, 1 - self.epsilon)

        # Convert targets to one-hot if needed
        if targets.ndim == 1 or (targets.ndim == 2 and targets.shape[1] == 1):
            targets_flat = targets.flatten().astype(int)
            targets_onehot = np.zeros((batch_size, num_classes))

            # Create mask for valid indices
            valid_mask = targets_flat != self.ignore_index
            valid_indices = targets_flat[valid_mask]

            # Set one-hot values
            targets_onehot[valid_mask, valid_indices] = 1.0
            self._mask = valid_mask
        else:
            targets_onehot = targets
            self._mask = np.ones(batch_size, dtype=bool)

        # Apply label smoothing
        if self.label_smoothing > 0:
            targets_onehot = self._apply_label_smoothing(
                targets_onehot, num_classes
            )

        # Cache for backward pass
        self._probs = probs_clipped
        self._targets = targets_onehot

        # Compute cross-entropy: -sum(y * log(p))
        log_probs = np.log(probs_clipped)
        loss_per_sample = -np.sum(targets_onehot * log_probs, axis=1)

        # Apply mask
        loss_per_sample = np.where(self._mask, loss_per_sample, 0.0)

        # Apply reduction
        if self.reduction == 'none':
            return loss_per_sample
        elif self.reduction == 'sum':
            return np.sum(loss_per_sample)
        else:  # mean
            valid_count = np.sum(self._mask)
            if valid_count == 0:
                return 0.0
            return np.sum(loss_per_sample) / valid_count

    def backward(self) -> np.ndarray:
        """
        Compute gradient of cross-entropy loss with respect to probabilities.

        dL/dp_i = -y_i / p_i

        Returns:
            Gradient with respect to input probabilities
        """
        if self._probs is None:
            raise RuntimeError("Must call forward before backward")

        # Gradient: -y / p
        grad = -self._targets / self._probs

        # Apply mask
        grad = grad * self._mask.reshape(-1, 1)

        # Apply reduction scaling
        if self.reduction == 'mean':
            valid_count = np.sum(self._mask)
            if valid_count > 0:
                grad = grad / valid_count

        return grad

    def _apply_label_smoothing(
        self,
        targets: np.ndarray,
        num_classes: int
    ) -> np.ndarray:
        """
        Apply label smoothing to targets.

        Smoothed label = (1 - alpha) * one_hot + alpha / num_classes

        Args:
            targets: One-hot encoded targets
            num_classes: Number of classes

        Returns:
            Smoothed targets
        """
        smooth_value = self.label_smoothing / num_classes
        return (1 - self.label_smoothing) * targets + smooth_value

    def __call__(
        self,
        probs: np.ndarray,
        targets: np.ndarray
    ) -> Union[float, np.ndarray]:
        """Allow using instance as a function."""
        return self.forward(probs, targets)


def cross_entropy_loss(
    probs: np.ndarray,
    targets: np.ndarray,
    epsilon: float = 1e-12
) -> float:
    """
    Simple cross-entropy loss function.

    Args:
        probs: Predicted probabilities, shape (N, C)
        targets: Target class indices, shape (N,)
        epsilon: Small constant for numerical stability

    Returns:
        Mean cross-entropy loss
    """
    batch_size = probs.shape[0]

    # Clip for stability
    probs_clipped = np.clip(probs, epsilon, 1 - epsilon)

    # Get probability of true class for each sample
    true_class_probs = probs_clipped[np.arange(batch_size), targets]

    # Cross-entropy: -log(p_true)
    loss = -np.log(true_class_probs)

    return np.mean(loss)


def binary_cross_entropy(
    probs: np.ndarray,
    targets: np.ndarray,
    epsilon: float = 1e-12
) -> float:
    """
    Binary cross-entropy loss.

    BCE = -[y * log(p) + (1-y) * log(1-p)]

    Args:
        probs: Predicted probabilities, shape (N,)
        targets: Binary targets (0 or 1), shape (N,)
        epsilon: Small constant for numerical stability

    Returns:
        Mean binary cross-entropy loss
    """
    probs_clipped = np.clip(probs, epsilon, 1 - epsilon)

    loss = -(
        targets * np.log(probs_clipped) +
        (1 - targets) * np.log(1 - probs_clipped)
    )

    return np.mean(loss)

Combined Softmax + Cross-Entropy (Numerically Stable)

python
class SoftmaxCrossEntropyLoss:
    """
    Combined Softmax and Cross-Entropy loss.

    This combined implementation is:
    1. More numerically stable
    2. More computationally efficient
    3. Has simpler gradients

    The loss is computed as:
    L = -z_y + log(sum(exp(z_i)))
      = -z_y + max(z) + log(sum(exp(z_i - max(z))))
    """

    def __init__(
        self,
        reduction: str = 'mean',
        label_smoothing: float = 0.0,
        ignore_index: int = -100
    ):
        """
        Initialize SoftmaxCrossEntropyLoss.

        Args:
            reduction: 'none', 'mean', or 'sum'
            label_smoothing: Smoothing factor
            ignore_index: Index to ignore
        """
        self.reduction = reduction
        self.label_smoothing = label_smoothing
        self.ignore_index = ignore_index

        # Cache for backward
        self._logits = None
        self._targets = None
        self._softmax_output = None
        self._mask = None

    def forward(
        self,
        logits: np.ndarray,
        targets: np.ndarray
    ) -> Union[float, np.ndarray]:
        """
        Compute combined softmax + cross-entropy loss.

        Uses log-sum-exp trick for numerical stability.

        Args:
            logits: Raw scores, shape (N, C)
            targets: Target class indices, shape (N,)

        Returns:
            Loss value(s)
        """
        if logits.ndim == 1:
            logits = logits.reshape(1, -1)

        batch_size, num_classes = logits.shape

        # Cache for backward
        self._logits = logits

        # Handle targets
        if targets.ndim == 1:
            targets_flat = targets.astype(int)
        else:
            targets_flat = targets.flatten().astype(int)

        self._targets = targets_flat

        # Create mask for valid indices
        self._mask = targets_flat != self.ignore_index

        # Compute log-sum-exp with numerical stability
        max_logits = np.max(logits, axis=1, keepdims=True)
        shifted_logits = logits - max_logits
        log_sum_exp = max_logits.squeeze() + np.log(
            np.sum(np.exp(shifted_logits), axis=1)
        )

        # Get logits of true class
        # Handle ignore_index by using 0 (will be masked anyway)
        safe_targets = np.where(self._mask, targets_flat, 0)
        logits_true_class = logits[np.arange(batch_size), safe_targets]

        # Apply label smoothing if needed
        if self.label_smoothing > 0:
            # With smoothing: L = (1-alpha)*L_hard + alpha*L_uniform
            # L_uniform = log(C) - mean(logits) + log_sum_exp
            loss_hard = log_sum_exp - logits_true_class
            loss_uniform = log_sum_exp - np.mean(logits, axis=1)
            loss_per_sample = (
                (1 - self.label_smoothing) * loss_hard +
                self.label_smoothing * loss_uniform
            )
        else:
            # Standard cross-entropy: -log(softmax(z)_y) = log_sum_exp - z_y
            loss_per_sample = log_sum_exp - logits_true_class

        # Apply mask
        loss_per_sample = np.where(self._mask, loss_per_sample, 0.0)

        # Cache softmax output for backward pass
        self._softmax_output = softmax_stable(logits, axis=1)

        # Apply reduction
        if self.reduction == 'none':
            return loss_per_sample
        elif self.reduction == 'sum':
            return np.sum(loss_per_sample)
        else:  # mean
            valid_count = np.sum(self._mask)
            if valid_count == 0:
                return 0.0
            return np.sum(loss_per_sample) / valid_count

    def backward(self) -> np.ndarray:
        """
        Compute gradient with respect to logits.

        The beautiful result: dL/dz_i = softmax(z)_i - y_i

        Returns:
            Gradient with respect to logits
        """
        if self._logits is None:
            raise RuntimeError("Must call forward before backward")

        batch_size, num_classes = self._logits.shape

        # Start with softmax output
        grad = self._softmax_output.copy()

        # Subtract 1 from true class (equivalent to subtracting one-hot)
        safe_targets = np.where(self._mask, self._targets, 0)
        grad[np.arange(batch_size), safe_targets] -= 1

        # Handle label smoothing gradient
        if self.label_smoothing > 0:
            # Adjust gradient for smoothing
            grad = (1 - self.label_smoothing) * grad
            grad += self.label_smoothing * (
                self._softmax_output - 1.0 / num_classes
            )

        # Apply mask
        grad = grad * self._mask.reshape(-1, 1)

        # Apply reduction scaling
        if self.reduction == 'mean':
            valid_count = np.sum(self._mask)
            if valid_count > 0:
                grad = grad / valid_count

        return grad

    def __call__(
        self,
        logits: np.ndarray,
        targets: np.ndarray
    ) -> Union[float, np.ndarray]:
        """Allow using instance as a function."""
        return self.forward(logits, targets)


def softmax_cross_entropy_with_logits(
    logits: np.ndarray,
    targets: np.ndarray
) -> Tuple[float, np.ndarray]:
    """
    Compute softmax cross-entropy loss and gradient in one pass.

    This is the most efficient way to compute both.

    Args:
        logits: Raw scores, shape (N, C)
        targets: Target class indices, shape (N,)

    Returns:
        Tuple of (loss, gradient)
    """
    batch_size, num_classes = logits.shape

    # Compute stable softmax
    max_logits = np.max(logits, axis=1, keepdims=True)
    shifted = logits - max_logits
    exp_shifted = np.exp(shifted)
    softmax_probs = exp_shifted / np.sum(exp_shifted, axis=1, keepdims=True)

    # Compute loss: -log(p_y) = log_sum_exp - z_y
    log_sum_exp = max_logits.squeeze() + np.log(
        np.sum(exp_shifted, axis=1)
    )
    logits_true = logits[np.arange(batch_size), targets]
    loss = np.mean(log_sum_exp - logits_true)

    # Compute gradient: softmax - one_hot
    grad = softmax_probs.copy()
    grad[np.arange(batch_size), targets] -= 1
    grad = grad / batch_size  # For mean reduction

    return loss, grad

Gradient/Backward Pass

python
class SoftmaxCrossEntropyWithGradients:
    """
    Complete implementation with full gradient computation.

    Demonstrates the full forward and backward pass including:
    - Softmax forward
    - Cross-entropy forward
    - Combined backward pass
    """

    def __init__(self):
        self.cache = {}

    def forward(
        self,
        logits: np.ndarray,
        targets: np.ndarray
    ) -> float:
        """
        Forward pass computing loss.

        Args:
            logits: Shape (N, C)
            targets: Shape (N,) with class indices

        Returns:
            Scalar loss value
        """
        batch_size, num_classes = logits.shape

        # Step 1: Compute stable softmax
        max_logits = np.max(logits, axis=1, keepdims=True)
        shifted = logits - max_logits
        exp_shifted = np.exp(shifted)
        sum_exp = np.sum(exp_shifted, axis=1, keepdims=True)
        probs = exp_shifted / sum_exp

        # Step 2: Compute cross-entropy loss
        # Using log probabilities directly for stability
        log_probs = shifted - np.log(sum_exp)

        # Get log probability of true class
        true_log_probs = log_probs[np.arange(batch_size), targets]
        loss = -np.mean(true_log_probs)

        # Cache values needed for backward pass
        self.cache = {
            'probs': probs,
            'targets': targets,
            'batch_size': batch_size,
            'num_classes': num_classes
        }

        return loss

    def backward(self) -> np.ndarray:
        """
        Backward pass computing gradient w.r.t. logits.

        Mathematical derivation:
        L = -1/N * sum_n(log(p_y_n))

        For softmax: p_i = exp(z_i) / sum(exp(z_j))

        Chain rule:
        dL/dz_i = dL/dp * dp/dz_i

        After simplification:
        dL/dz_i = (1/N) * (p_i - 1{i=y})

        Returns:
            Gradient with respect to input logits
        """
        probs = self.cache['probs']
        targets = self.cache['targets']
        batch_size = self.cache['batch_size']

        # Gradient is simply: (probs - one_hot) / batch_size
        grad = probs.copy()
        grad[np.arange(batch_size), targets] -= 1.0
        grad /= batch_size

        return grad

    def numerical_gradient(
        self,
        logits: np.ndarray,
        targets: np.ndarray,
        epsilon: float = 1e-5
    ) -> np.ndarray:
        """
        Compute numerical gradient for verification.

        Uses finite differences: (f(x+h) - f(x-h)) / (2h)

        Args:
            logits: Input logits
            targets: Target labels
            epsilon: Finite difference step size

        Returns:
            Numerical gradient approximation
        """
        grad = np.zeros_like(logits)

        for i in range(logits.shape[0]):
            for j in range(logits.shape[1]):
                # f(x + h)
                logits_plus = logits.copy()
                logits_plus[i, j] += epsilon
                loss_plus = self.forward(logits_plus, targets)

                # f(x - h)
                logits_minus = logits.copy()
                logits_minus[i, j] -= epsilon
                loss_minus = self.forward(logits_minus, targets)

                # Central difference
                grad[i, j] = (loss_plus - loss_minus) / (2 * epsilon)

        return grad

    def verify_gradient(
        self,
        logits: np.ndarray,
        targets: np.ndarray
    ) -> dict:
        """
        Verify analytical gradient against numerical gradient.

        Returns:
            Dictionary with comparison metrics
        """
        # Compute analytical gradient
        self.forward(logits, targets)
        analytical_grad = self.backward()

        # Compute numerical gradient
        numerical_grad = self.numerical_gradient(logits, targets)

        # Compute relative error
        diff = np.abs(analytical_grad - numerical_grad)
        rel_error = diff / (np.abs(analytical_grad) + np.abs(numerical_grad) + 1e-8)

        return {
            'analytical_grad': analytical_grad,
            'numerical_grad': numerical_grad,
            'max_absolute_error': np.max(diff),
            'max_relative_error': np.max(rel_error),
            'mean_relative_error': np.mean(rel_error),
            'gradient_check_passed': np.max(rel_error) < 1e-5
        }


def compute_jacobian_softmax(probs: np.ndarray) -> np.ndarray:
    """
    Compute the full Jacobian matrix of softmax.

    J[i,j] = d(softmax_i) / d(z_j) = p_i * (delta_ij - p_j)

    Args:
        probs: Softmax output, shape (C,)

    Returns:
        Jacobian matrix, shape (C, C)
    """
    n = len(probs)
    jacobian = np.zeros((n, n))

    for i in range(n):
        for j in range(n):
            if i == j:
                jacobian[i, j] = probs[i] * (1 - probs[i])
            else:
                jacobian[i, j] = -probs[i] * probs[j]

    # Equivalent vectorized form:
    # jacobian = np.diag(probs) - np.outer(probs, probs)

    return jacobian

PyTorch Comparison

python
import torch
import torch.nn.functional as F


def compare_implementations():
    """
    Compare our implementation with PyTorch.
    """
    np.random.seed(42)
    torch.manual_seed(42)

    # Test data
    batch_size, num_classes = 4, 5
    logits_np = np.random.randn(batch_size, num_classes).astype(np.float32)
    targets_np = np.random.randint(0, num_classes, batch_size)

    # Convert to PyTorch
    logits_torch = torch.tensor(logits_np, requires_grad=True)
    targets_torch = torch.tensor(targets_np, dtype=torch.long)

    print("=" * 60)
    print("COMPARISON: Custom Implementation vs PyTorch")
    print("=" * 60)

    # 1. Softmax comparison
    print("\n1. SOFTMAX COMPARISON")
    print("-" * 40)

    # Our implementation
    softmax_custom = softmax_stable(logits_np)

    # PyTorch
    softmax_pytorch = F.softmax(logits_torch, dim=1).detach().numpy()

    print(f"Max absolute difference: {np.max(np.abs(softmax_custom - softmax_pytorch)):.2e}")
    print(f"Match: {np.allclose(softmax_custom, softmax_pytorch)}")

    # 2. Log-softmax comparison
    print("\n2. LOG-SOFTMAX COMPARISON")
    print("-" * 40)

    log_softmax_custom = log_softmax(logits_np)
    log_softmax_pytorch = F.log_softmax(logits_torch, dim=1).detach().numpy()

    print(f"Max absolute difference: {np.max(np.abs(log_softmax_custom - log_softmax_pytorch)):.2e}")
    print(f"Match: {np.allclose(log_softmax_custom, log_softmax_pytorch)}")

    # 3. Cross-entropy loss comparison
    print("\n3. CROSS-ENTROPY LOSS COMPARISON")
    print("-" * 40)

    # Our implementation
    loss_fn = SoftmaxCrossEntropyLoss(reduction='mean')
    loss_custom = loss_fn.forward(logits_np, targets_np)

    # PyTorch
    loss_pytorch = F.cross_entropy(logits_torch, targets_torch).item()

    print(f"Custom loss: {loss_custom:.6f}")
    print(f"PyTorch loss: {loss_pytorch:.6f}")
    print(f"Difference: {abs(loss_custom - loss_pytorch):.2e}")
    print(f"Match: {np.isclose(loss_custom, loss_pytorch)}")

    # 4. Gradient comparison
    print("\n4. GRADIENT COMPARISON")
    print("-" * 40)

    # Our gradient
    grad_custom = loss_fn.backward()

    # PyTorch gradient
    logits_torch_grad = torch.tensor(logits_np, requires_grad=True)
    loss_torch = F.cross_entropy(logits_torch_grad, targets_torch)
    loss_torch.backward()
    grad_pytorch = logits_torch_grad.grad.numpy()

    print(f"Max gradient difference: {np.max(np.abs(grad_custom - grad_pytorch)):.2e}")
    print(f"Gradients match: {np.allclose(grad_custom, grad_pytorch, rtol=1e-5)}")

    # 5. Label smoothing comparison
    print("\n5. LABEL SMOOTHING COMPARISON")
    print("-" * 40)

    smoothing = 0.1

    # Our implementation
    loss_fn_smooth = SoftmaxCrossEntropyLoss(
        reduction='mean',
        label_smoothing=smoothing
    )
    loss_smooth_custom = loss_fn_smooth.forward(logits_np, targets_np)

    # PyTorch
    loss_smooth_pytorch = F.cross_entropy(
        torch.tensor(logits_np),
        torch.tensor(targets_np, dtype=torch.long),
        label_smoothing=smoothing
    ).item()

    print(f"Custom loss (smoothed): {loss_smooth_custom:.6f}")
    print(f"PyTorch loss (smoothed): {loss_smooth_pytorch:.6f}")
    print(f"Difference: {abs(loss_smooth_custom - loss_smooth_pytorch):.2e}")

    # 6. Numerical stability test
    print("\n6. NUMERICAL STABILITY TEST")
    print("-" * 40)

    # Large logits that would overflow naive implementation
    large_logits = np.array([[1000.0, 1001.0, 1002.0]])

    try:
        naive_result = softmax_naive(large_logits)
        print(f"Naive softmax: {naive_result}")
        print(f"Contains NaN/Inf: {np.any(np.isnan(naive_result) | np.isinf(naive_result))}")
    except:
        print("Naive softmax: FAILED (overflow)")

    stable_result = softmax_stable(large_logits)
    print(f"Stable softmax: {stable_result}")
    print(f"Sum equals 1: {np.isclose(np.sum(stable_result), 1.0)}")

    print("\n" + "=" * 60)
    print("All comparisons completed!")
    print("=" * 60)


def benchmark_implementations():
    """
    Benchmark custom vs PyTorch implementations.
    """
    import time

    # Test sizes
    sizes = [(32, 10), (128, 100), (256, 1000), (512, 10000)]

    print("\nBENCHMARK: Custom vs PyTorch")
    print("=" * 70)
    print(f"{'Size':<20} {'Custom (ms)':<15} {'PyTorch (ms)':<15} {'Ratio':<10}")
    print("-" * 70)

    for batch_size, num_classes in sizes:
        logits_np = np.random.randn(batch_size, num_classes).astype(np.float32)
        targets_np = np.random.randint(0, num_classes, batch_size)

        logits_torch = torch.tensor(logits_np, requires_grad=True)
        targets_torch = torch.tensor(targets_np, dtype=torch.long)

        # Warm up
        loss_fn = SoftmaxCrossEntropyLoss()
        _ = loss_fn.forward(logits_np, targets_np)
        _ = F.cross_entropy(logits_torch, targets_torch)

        # Benchmark custom
        n_runs = 100
        start = time.perf_counter()
        for _ in range(n_runs):
            loss = loss_fn.forward(logits_np, targets_np)
            grad = loss_fn.backward()
        custom_time = (time.perf_counter() - start) * 1000 / n_runs

        # Benchmark PyTorch
        start = time.perf_counter()
        for _ in range(n_runs):
            logits_torch = torch.tensor(logits_np, requires_grad=True)
            loss = F.cross_entropy(logits_torch, targets_torch)
            loss.backward()
        pytorch_time = (time.perf_counter() - start) * 1000 / n_runs

        size_str = f"({batch_size}, {num_classes})"
        ratio = custom_time / pytorch_time
        print(f"{size_str:<20} {custom_time:<15.3f} {pytorch_time:<15.3f} {ratio:<10.2f}x")


if __name__ == "__main__":
    compare_implementations()
    benchmark_implementations()

Complexity Analysis

Time Complexity

OperationComplexityNotes
Softmax forwardO(N * C)N = batch size, C = classes
Softmax backwardO(N * C)Element-wise operations
Cross-entropy forwardO(N * C)Log and multiply
Cross-entropy backwardO(N * C)Simple subtraction
Combined forwardO(N * C)Single pass
Combined backwardO(N * C)Most efficient

Space Complexity

OperationComplexityNotes
SoftmaxO(N * C)Store probabilities
Cross-entropyO(N * C)Store log probabilities
CombinedO(N * C)Only store softmax output
GradientsO(N * C)Same as input size

Numerical Considerations

python
def analyze_numerical_precision():
    """
    Analyze numerical precision characteristics.
    """
    print("NUMERICAL PRECISION ANALYSIS")
    print("=" * 60)

    # 1. Overflow threshold
    print("\n1. Overflow Threshold")
    print("-" * 40)

    # float32 max exponent
    max_float32 = np.finfo(np.float32).max
    max_exp = np.log(max_float32)
    print(f"Max float32: {max_float32:.2e}")
    print(f"Max safe exponent: {max_exp:.1f}")
    print("=> Logits > ~88 will overflow in naive softmax")

    # 2. Underflow considerations
    print("\n2. Underflow Threshold")
    print("-" * 40)

    min_float32 = np.finfo(np.float32).tiny
    min_exp = np.log(min_float32)
    print(f"Min positive float32: {min_float32:.2e}")
    print(f"Min safe exponent: {min_exp:.1f}")
    print("=> Very negative logits can underflow to 0")

    # 3. Log(0) issue
    print("\n3. Log(0) Protection")
    print("-" * 40)

    probs = np.array([0.9, 0.1, 0.0])
    print(f"Probabilities: {probs}")
    print(f"log(probs) without clipping: {np.log(probs + 1e-12)}")
    print("=> Always clip or add epsilon before log")

    # 4. Precision loss in subtraction
    print("\n4. Catastrophic Cancellation")
    print("-" * 40)

    # When probabilities are very close to 1
    logits = np.array([[100.0, 0.0, 0.0]])
    probs = softmax_stable(logits)
    print(f"Logits: {logits[0]}")
    print(f"Softmax: {probs[0]}")
    print(f"1 - p[0] = {1 - probs[0, 0]}")
    print("=> Stable implementation handles extreme cases")


analyze_numerical_precision()

Common Interview Follow-ups

1. Why Combine Softmax and Cross-Entropy?

Three key advantages:

  1. Numerical stability - Avoids log(0) by computing log-softmax directly
  2. Simpler gradients - Combined: dL/dz = p - y vs separate chain rule with Jacobian
  3. Efficiency - Single pass computation, no intermediate storage
python
def demonstrate_combined_advantages():
    """Show why combined implementation is better."""
    logits = np.array([[100.0, 101.0, 102.0]])
    targets = np.array([2])

    # Separate approach has numerical issues with extreme logits
    probs = softmax_stable(logits)
    print(f"Softmax output: {probs}")

    # Combined approach handles it cleanly
    loss_fn = SoftmaxCrossEntropyLoss()
    loss = loss_fn.forward(logits, targets)
    print(f"Combined loss: {loss:.6f}")

2. How to Handle Numerical Overflow?

Key strategies:

  1. Max subtraction - Shift logits by subtracting max before exp()
  2. Log-domain computation - Use log-sum-exp trick
  3. Chunked computation - Process large tensors in memory-efficient chunks
python
def overflow_handling_strategies():
    """Demonstrate overflow handling techniques."""
    large_logits = np.array([1000.0, 1001.0, 999.0])

    # Max subtraction (standard approach)
    shifted = large_logits - np.max(large_logits)
    result = np.exp(shifted) / np.sum(np.exp(shifted))
    print(f"Shifted logits: {shifted}, Result: {result}")

    # Log-domain computation
    log_probs = log_softmax(large_logits)
    print(f"Log probs: {log_probs}, Probs: {np.exp(log_probs)}")

3. Binary vs Categorical Cross-Entropy

Use CaseLoss TypeActivationProbs Sum to 1?
Multi-class (one correct)Categorical CESoftmaxYes
Binary classificationBinary CESigmoidN/A
Multi-label (multiple correct)Binary CESigmoidNo
python
def compare_binary_categorical():
    """Compare binary and categorical cross-entropy."""
    logits = np.array([2.0, -1.0, 0.5, -0.5])
    targets = np.array([1, 0, 1, 0])

    # Binary CE with sigmoid
    probs_binary = 1 / (1 + np.exp(-logits))
    bce_loss = binary_cross_entropy(probs_binary, targets)

    # Categorical CE with softmax (2-class)
    logits_2class = np.stack([np.zeros_like(logits), logits], axis=1)
    probs_2class = softmax_stable(logits_2class)
    ce_loss = cross_entropy_loss(probs_2class, targets)

    print(f"BCE: {bce_loss:.6f}, CE: {ce_loss:.6f}, Equal: {np.isclose(bce_loss, ce_loss)}")

Edge Cases and Pitfalls

Large Logits Causing Overflow

python
def test_large_logits():
    """Test handling of large logits."""
    for scale in [10, 100, 1000, 10000]:
        logits = np.array([[0.0, 1.0, 2.0]]) * scale
        stable = softmax_stable(logits)
        print(f"Scale {scale:>5}: Sum={np.sum(stable):.6f}, Finite={np.all(np.isfinite(stable))}")

Zero Probabilities

python
def test_zero_probabilities():
    """Test handling of near-zero probabilities."""
    logits = np.array([[100.0, 0.0, 0.0]])  # Extreme logits
    targets = np.array([1])  # Target is low-probability class

    loss_fn = SoftmaxCrossEntropyLoss()
    loss = loss_fn.forward(logits, targets)
    grad = loss_fn.backward()

    print(f"Loss: {loss:.6f}, Finite: {np.isfinite(loss)}")
    print(f"Gradient finite: {np.all(np.isfinite(grad))}")

Label Smoothing

Label smoothing replaces hard one-hot targets with soft distributions, preventing overconfidence and improving generalization.

Label Smoothing Effect

python
def test_label_smoothing():
    """Test label smoothing implementation."""
    logits = np.array([[1.0, 2.0, 3.0, 4.0, 5.0]])
    targets = np.array([4])

    for alpha in [0.0, 0.1, 0.2, 0.5]:
        loss_fn = SoftmaxCrossEntropyLoss(label_smoothing=alpha)
        loss = loss_fn.forward(logits, targets)
        print(f"Smoothing={alpha}: Loss={loss:.4f}")


test_label_smoothing()

Batch with Mixed Valid/Invalid Indices

python
def test_ignore_index():
    """
    Test ignore_index functionality.
    """
    print("\nEDGE CASE: Ignore Index")
    print("=" * 60)

    logits = np.array([
        [1.0, 2.0, 3.0],
        [1.0, 2.0, 3.0],
        [1.0, 2.0, 3.0],
        [1.0, 2.0, 3.0]
    ])

    # Some targets are padding (-100)
    targets = np.array([0, 1, -100, 2])

    loss_fn = SoftmaxCrossEntropyLoss(ignore_index=-100)
    loss = loss_fn.forward(logits, targets)
    grad = loss_fn.backward()

    print(f"Targets: {targets}")
    print(f"Loss (mean over valid): {loss:.6f}")
    print(f"Gradient:\n{grad}")
    print(f"Gradient for ignored sample (row 2): {grad[2]}")
    print(f"Is zero: {np.allclose(grad[2], 0)}")


test_ignore_index()

Complete Test Suite

python
def run_all_tests():
    """
    Run comprehensive test suite.
    """
    print("=" * 70)
    print("COMPREHENSIVE TEST SUITE")
    print("=" * 70)

    tests_passed = 0
    tests_failed = 0

    # Test 1: Basic softmax
    print("\nTest 1: Basic Softmax")
    try:
        logits = np.array([[1.0, 2.0, 3.0]])
        result = softmax_stable(logits)
        assert np.isclose(np.sum(result), 1.0), "Softmax should sum to 1"
        assert np.all(result > 0), "Softmax should be positive"
        assert np.all(result < 1), "Softmax should be less than 1"
        print("  PASSED")
        tests_passed += 1
    except AssertionError as e:
        print(f"  FAILED: {e}")
        tests_failed += 1

    # Test 2: Softmax numerical stability
    print("\nTest 2: Softmax Numerical Stability")
    try:
        large_logits = np.array([[1000.0, 1001.0, 1002.0]])
        result = softmax_stable(large_logits)
        assert np.isclose(np.sum(result), 1.0), "Should sum to 1"
        assert np.all(np.isfinite(result)), "Should be finite"
        print("  PASSED")
        tests_passed += 1
    except AssertionError as e:
        print(f"  FAILED: {e}")
        tests_failed += 1

    # Test 3: Cross-entropy with probabilities
    print("\nTest 3: Cross-Entropy Loss")
    try:
        probs = np.array([[0.7, 0.2, 0.1]])
        targets = np.array([0])
        loss = cross_entropy_loss(probs, targets)
        expected = -np.log(0.7)
        assert np.isclose(loss, expected), f"Expected {expected}, got {loss}"
        print("  PASSED")
        tests_passed += 1
    except AssertionError as e:
        print(f"  FAILED: {e}")
        tests_failed += 1

    # Test 4: Combined loss
    print("\nTest 4: Combined Softmax + Cross-Entropy")
    try:
        logits = np.array([[1.0, 2.0, 3.0]])
        targets = np.array([2])

        # Method 1: Separate
        probs = softmax_stable(logits)
        loss1 = -np.log(probs[0, targets[0]])

        # Method 2: Combined
        loss_fn = SoftmaxCrossEntropyLoss(reduction='none')
        loss2 = loss_fn.forward(logits, targets)[0]

        assert np.isclose(loss1, loss2), f"Losses should match: {loss1} vs {loss2}"
        print("  PASSED")
        tests_passed += 1
    except AssertionError as e:
        print(f"  FAILED: {e}")
        tests_failed += 1

    # Test 5: Gradient correctness
    print("\nTest 5: Gradient Correctness")
    try:
        logits = np.random.randn(2, 4)
        targets = np.array([1, 3])

        grad_checker = SoftmaxCrossEntropyWithGradients()
        result = grad_checker.verify_gradient(logits, targets)

        assert result['gradient_check_passed'], \
            f"Max rel error: {result['max_relative_error']}"
        print("  PASSED")
        tests_passed += 1
    except AssertionError as e:
        print(f"  FAILED: {e}")
        tests_failed += 1

    # Test 6: Batch processing
    print("\nTest 6: Batch Processing")
    try:
        batch_size = 32
        num_classes = 10

        logits = np.random.randn(batch_size, num_classes)
        targets = np.random.randint(0, num_classes, batch_size)

        loss_fn = SoftmaxCrossEntropyLoss()
        loss = loss_fn.forward(logits, targets)
        grad = loss_fn.backward()

        assert np.isfinite(loss), "Loss should be finite"
        assert grad.shape == logits.shape, "Gradient shape should match input"
        assert np.all(np.isfinite(grad)), "Gradient should be finite"
        print("  PASSED")
        tests_passed += 1
    except AssertionError as e:
        print(f"  FAILED: {e}")
        tests_failed += 1

    # Test 7: Label smoothing
    print("\nTest 7: Label Smoothing")
    try:
        logits = np.array([[1.0, 2.0, 3.0]])
        targets = np.array([2])

        loss_no_smooth = SoftmaxCrossEntropyLoss(label_smoothing=0.0)
        loss_smooth = SoftmaxCrossEntropyLoss(label_smoothing=0.1)

        l1 = loss_no_smooth.forward(logits, targets)
        l2 = loss_smooth.forward(logits, targets)

        # Smoothed loss should be higher (more uncertain)
        assert l2 > l1, "Smoothed loss should be higher"
        print("  PASSED")
        tests_passed += 1
    except AssertionError as e:
        print(f"  FAILED: {e}")
        tests_failed += 1

    # Test 8: Ignore index
    print("\nTest 8: Ignore Index")
    try:
        logits = np.array([[1.0, 2.0], [1.0, 2.0]])
        targets = np.array([0, -100])

        loss_fn = SoftmaxCrossEntropyLoss(ignore_index=-100)
        loss = loss_fn.forward(logits, targets)
        grad = loss_fn.backward()

        # Second sample should have zero gradient
        assert np.allclose(grad[1], 0), "Ignored sample should have zero gradient"
        print("  PASSED")
        tests_passed += 1
    except AssertionError as e:
        print(f"  FAILED: {e}")
        tests_failed += 1

    # Test 9: Binary cross-entropy
    print("\nTest 9: Binary Cross-Entropy")
    try:
        probs = np.array([0.9, 0.1, 0.8])
        targets = np.array([1, 0, 1])

        loss = binary_cross_entropy(probs, targets)

        # Manual calculation
        expected = -np.mean([
            np.log(0.9),      # target=1, pred=0.9
            np.log(0.9),      # target=0, pred=0.1 -> log(1-0.1)
            np.log(0.8)       # target=1, pred=0.8
        ])

        assert np.isclose(loss, expected), f"Expected {expected}, got {loss}"
        print("  PASSED")
        tests_passed += 1
    except AssertionError as e:
        print(f"  FAILED: {e}")
        tests_failed += 1

    # Test 10: PyTorch comparison
    print("\nTest 10: PyTorch Comparison")
    try:
        logits_np = np.random.randn(8, 5).astype(np.float32)
        targets_np = np.random.randint(0, 5, 8)

        # Our implementation
        loss_fn = SoftmaxCrossEntropyLoss()
        our_loss = loss_fn.forward(logits_np, targets_np)

        # PyTorch
        logits_torch = torch.tensor(logits_np)
        targets_torch = torch.tensor(targets_np, dtype=torch.long)
        pytorch_loss = F.cross_entropy(logits_torch, targets_torch).item()

        assert np.isclose(our_loss, pytorch_loss, rtol=1e-5), \
            f"Our: {our_loss}, PyTorch: {pytorch_loss}"
        print("  PASSED")
        tests_passed += 1
    except AssertionError as e:
        print(f"  FAILED: {e}")
        tests_failed += 1

    # Summary
    print("\n" + "=" * 70)
    print(f"RESULTS: {tests_passed} passed, {tests_failed} failed")
    print("=" * 70)

    return tests_passed, tests_failed


# Run tests
if __name__ == "__main__":
    run_all_tests()

Key Takeaways

  1. Always use numerically stable softmax - Subtract max before exponentiating
  2. Combine softmax and cross-entropy - Simpler gradients, better stability
  3. Use log-sum-exp trick - Prevents overflow in log computations
  4. Understand the gradient - dL/dz = softmax(z) - y is beautifully simple
  5. Handle edge cases - Zero probabilities, ignore indices, label smoothing
  6. Choose the right loss - Binary for multi-label, categorical for multi-class

Interview Tips

  • Start by explaining the math before coding
  • Mention numerical stability immediately
  • Know why combined implementation is preferred
  • Be ready to derive the gradient by hand
  • Understand the relationship between softmax temperature and confidence
  • Know when to use binary vs categorical cross-entropy