Batch Normalization › Code Implementation

Michael BrenndoerferApril 19, 20258 min read
Language AI HandbookAll chapters
Part 10 of 67Neural Network Foundations
Chapter 71Batch Normalization › Code Implementation
Reading section 13 of 17 · Code Implementation · 8 minSection 13 of 17 · 8 min

Book contents

Section 13 of 17 · 8 min read

Batch Normalization › Code ImplementationOne section · Sections in this chapter13 / 17 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

This section walks through implementing batch normalization from scratch in PyTorch, then shows how to use PyTorch's built-in nn.BatchNorm1d and nn.BatchNorm2d modules in a realistic network. The goal is to solidify your understanding of both the mechanics and the practical pitfalls.

Batch Norm from Scratch

Let's implement batch normalization from scratch to see exactly how the forward pass works, including the training/inference mode switch.

In[8]:
Code
import torch


class BatchNorm1dManual:
    """
    Manual batch normalization for 1D inputs.
    Demonstrates the training vs inference modes.
    """

    def __init__(self, num_features, eps=1e-5, momentum=0.1):
        self.num_features = num_features
        self.eps = eps
        self.momentum = momentum

        # Learnable parameters
        self.gamma = torch.ones(num_features)
        self.beta = torch.zeros(num_features)

        # Running statistics (updated during training, used during inference)
        self.running_mean = torch.zeros(num_features)
        self.running_var = torch.ones(num_features)

        self.training = True

    def forward(self, x):
        if self.training:
            # Compute batch statistics
            batch_mean = x.mean(dim=0)
            batch_var = x.var(dim=0, unbiased=False)  # Population variance

            # Normalize using batch statistics
            x_hat = (x - batch_mean) / torch.sqrt(batch_var + self.eps)

            # Update running statistics with exponential moving average
            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
        else:
            # Use running statistics for inference
            x_hat = (x - self.running_mean) / torch.sqrt(
                self.running_var + self.eps
            )

        # Scale and shift with learned parameters
        return self.gamma * x_hat + self.beta
Out[9]:
Console
Input activations:
[[2. 1. 3.]
 [4. 3. 1.]
 [3. 2. 4.]
 [1. 4. 2.]]

Input mean per feature: [2.5, 2.5, 2.5]
Input std per feature:  [1.291, 1.291, 1.291]

Output after batch norm (training mode):
[[-0.4472 -1.3416  0.4472]
 [ 1.3416  0.4472 -1.3416]
 [ 0.4472 -0.4472  1.3416]
 [-1.3416  1.3416 -0.4472]]

Output mean per feature: [0.0, 0.0, 0.0]
Output std per feature:  [1.1547, 1.1547, 1.1547]

The output shows that batch normalization brings the mean of each feature very close to zero and the standard deviation close to one. With the initial γ=1\gamma = 1 and β=0\beta = 0, the output is simply the normalized input.

Using PyTorch's Built-in Batch Norm

PyTorch provides nn.BatchNorm1d (for fully connected layers) and nn.BatchNorm2d (for convolutional layers). Let's see how to use them and inspect their internal state.

In[10]:
Code
# BatchNorm1d: for fully connected layers
# Input shape: (batch_size, num_features)
bn1d = nn.BatchNorm1d(num_features=128)

# BatchNorm2d: for convolutional layers
# Input shape: (batch_size, channels, height, width)
bn2d = nn.BatchNorm2d(num_features=64)  # 64 channels
Out[11]:
Console
BatchNorm1d: gamma (weight) shape = torch.Size([128]), beta (bias) shape = torch.Size([128])
  gamma initialized to all 1.0: True
  beta initialized to all 0.0:  True

BatchNorm2d: gamma (weight) shape = torch.Size([64]) (one per channel)

Running mean (first 3): [0.0, 0.0, 0.0]
Running var  (first 3): [1.0, 1.0, 1.0]

PyTorch initializes gamma (weight) to ones and beta (bias) to zeros, matching our manual implementation. The running statistics start at zero mean and unit variance.

Building a Network with Batch Normalization

Let's build a small fully connected classifier that incorporates batch normalization, and observe the effect on training stability compared to a network without it.

In[12]:
Code
from sklearn.datasets import make_classification
from sklearn.preprocessing import StandardScaler
from torch.utils.data import DataLoader, TensorDataset

torch.manual_seed(42)
np.random.seed(42)

# Generate a synthetic classification dataset
X_np, y_np = make_classification(
    n_samples=1000,
    n_features=20,
    n_informative=10,
    n_redundant=5,
    n_classes=2,
    class_sep=0.8,
    random_state=42,
)

# Standardize inputs
scaler = StandardScaler()
X_np = scaler.fit_transform(X_np).astype(np.float32)
y_np = y_np.astype(np.int64)

# Split into train/test
split = 800
X_train = torch.tensor(X_np[:split])
y_train = torch.tensor(y_np[:split])
X_test = torch.tensor(X_np[split:])
y_test = torch.tensor(y_np[split:])

train_loader = DataLoader(
    TensorDataset(X_train, y_train), batch_size=64, shuffle=True
)
In[13]:
Code
# Define two architectures: with and without batch normalization
class MLPWithBN(nn.Module):
    def __init__(self, input_dim, hidden_dim, output_dim):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(input_dim, hidden_dim),
            nn.BatchNorm1d(hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim),
            nn.BatchNorm1d(hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, output_dim),
        )

    def forward(self, x):
        return self.net(x)


class MLPWithoutBN(nn.Module):
    def __init__(self, input_dim, hidden_dim, output_dim):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(input_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, output_dim),
        )

    def forward(self, x):
        return self.net(x)


def train_model(model, loader, n_epochs=30, lr=0.01):
    optimizer = optim.SGD(model.parameters(), lr=lr)
    criterion = nn.CrossEntropyLoss()
    losses = []
    for epoch in range(n_epochs):
        model.train()
        epoch_loss = 0.0
        for X_batch, y_batch in loader:
            optimizer.zero_grad()
            logits = model(X_batch)
            loss = criterion(logits, y_batch)
            loss.backward()
            optimizer.step()
            epoch_loss += loss.item()
        losses.append(epoch_loss / len(loader))
    return losses


# Train both models - batch norm allows a higher learning rate
model_bn = MLPWithBN(input_dim=20, hidden_dim=64, output_dim=2)
model_no_bn = MLPWithoutBN(input_dim=20, hidden_dim=64, output_dim=2)

losses_bn = train_model(model_bn, train_loader, n_epochs=30, lr=0.05)
losses_no_bn = train_model(model_no_bn, train_loader, n_epochs=30, lr=0.01)
In[14]:
Code
# Evaluate both models on the test set
def evaluate_model(model, X, y):
    model.eval()
    with torch.no_grad():
        logits = model(X)
        preds = logits.argmax(dim=1)
        accuracy = (preds == y).float().mean().item()
    return accuracy


acc_bn = evaluate_model(model_bn, X_test, y_test)
acc_no_bn = evaluate_model(model_no_bn, X_test, y_test)
Out[15]:
Console
Test accuracy WITH batch normalization:    0.935
Test accuracy WITHOUT batch normalization: 0.715

Final training loss WITH batch norm:    0.0820
Final training loss WITHOUT batch norm: 0.6064

Batch norm model trained with lr=0.05; plain model trained with lr=0.01
(Higher lr without batch norm would diverge)

The model with batch normalization achieves better accuracy and lower training loss, even though it used a higher learning rate (0.05 vs 0.01). Without batch normalization, using the higher learning rate would likely cause training to diverge. This demonstrates one of the most practically important effects of batch normalization: it allows more aggressive learning rates, which directly translate to faster convergence and better final performance within a fixed training budget.

The training curves below show how batch normalization enables faster convergence.

Out[16]:
Visualization
Line plot comparing training loss curves for models with and without batch normalization.
Training loss over 30 epochs for networks with and without batch normalization. The batch-normalized model (trained with lr=0.05) converges faster and to a lower final loss than the plain network (trained with lr=0.01). Using lr=0.05 without batch normalization would cause divergence, illustrating how batch normalization widens the stable learning rate range.

The training=True vs training=False Distinction

One of the most important practical aspects of batch normalization is correctly switching between training and inference modes. Let's verify how model.eval() changes behavior and understand why it matters.

In[17]:
Code
# Demonstrate the effect of switching to inference mode on batch norm behavior.
# We train briefly on a batch to warm up running statistics, then compare
# outputs between training mode and inference mode on the same single sample.
torch.manual_seed(42)
test_model = nn.Sequential(nn.Linear(5, 8), nn.BatchNorm1d(8), nn.ReLU())

# Pass a small training batch to warm up the running statistics
x_batch = torch.randn(16, 5)
test_model.train()
_ = test_model(x_batch)  # Updates running_mean and running_var

# Now test with a single sample in BOTH modes
x_single = torch.randn(1, 5)

# Training mode on a single sample raises ValueError in PyTorch.
# Use a 2-sample batch to approximate what training mode would do.
x_two = torch.cat([x_single, torch.randn(1, 5)], dim=0)
test_model.train()
out_train_approx = test_model(x_two)[0:1]  # Keep only the first sample's output

# Inference mode: uses stable running statistics, works with any batch size
test_model.eval()
out_inference = test_model(x_single)

outputs_match = torch.allclose(out_train_approx, out_inference, atol=1e-2)
Out[18]:
Console
Training mode output (first 4 values): [1. 0. 0. 1.]
Inference mode output (first 4 values): [0.     0.     0.     0.8766]

Outputs match (within tolerance of 0.01): False

Training mode uses live batch statistics, producing different outputs
depending on which other samples appear in the batch.
Inference mode uses fixed running statistics for consistent, deterministic predictions.
Always switch to inference mode before generating predictions!

With a batch size of 1 in training mode, batch normalization computes the variance of a single value, which is always zero. This produces the wrong result and PyTorch will raise an error. Switching to inference mode before generating predictions ensures the stable running statistics are used, which is always the correct behavior.

Key Parameters

The key parameters for nn.BatchNorm1d and nn.BatchNorm2d are:

  • num_features: The number of features (for BatchNorm1d) or channels (for BatchNorm2d). This determines the shape of the learnable γ\gamma and β\beta parameters.
  • eps: The small constant added to the variance for numerical stability. Default is 1e-5. Rarely needs tuning, but you might increase it to 1e-3 if you experience NaN losses due to very small variance estimates.
  • momentum: The momentum for updating running statistics. Default is 0.1 in PyTorch, meaning new batches contribute 10% to the running estimate. Use smaller values (e.g., 0.01) for very large datasets where you want the running statistics to update conservatively.
  • affine: If True (default), learnable γ\gamma and β\beta are included. If False, batch norm only normalizes without the scale and shift step. Setting affine=False is occasionally useful when you want normalization without additional parameters.
  • track_running_stats: If True (default), maintains running mean and variance for inference. If False, always uses batch statistics. This can be useful for fine-tuning scenarios where you want the normalization to adapt to a new domain's statistics.