So far, we have discussed “Sharp vs Flat” minima. But there is another geometric feature that ruins SGD’s day: Eccentricity.
Ideally, we want our loss function to look like a perfect bowl (Circle).
Gradient points directly to the center.
We can take a straight path to the minimum.
In reality, most loss functions are elongated valleys (Ellipses).
Steep in one direction (Walls of the valley).
Flat in the other direction (Floor of the valley).
The “Bouncing” Problem
Because the gradient corresponds to steepness, it is massive in the “steep” direction and tiny in the “flat” direction.
SGD takes a huge step into the wall, overshoots, and bounces back.
It barely moves along the valley floor. This zig-zagging behavior makes convergence painfully slow.
1. Visualizing Ill-Conditioning
Let’s visualize SGD trying to optimize a skewed quadratic bowl: f(x,y) = 10x^2 + y^2. * This valley is 10x steeper in x than y. * The Condition Number (Ratio of curvature) is 10.
Code
import numpy as npimport matplotlib.pyplot as pltdef skewed_loss(x, y):return20*x**2+ y**2# Very steep in X, flat in Ydef skewed_grad(x, y):return np.array([40*x, 2*y])# Run SGDlr =0.04# If we set this for the flat direction (y), x will explode!w = np.array([1.5, 1.5])path_x, path_y = [w[0]], [w[1]]for _ inrange(20): g = skewed_grad(w[0], w[1]) w = w - lr * g path_x.append(w[0]) path_y.append(w[1])# Plotx_grid = np.linspace(-2, 2, 100)y_grid = np.linspace(-2, 2, 100)X, Y = np.meshgrid(x_grid, y_grid)Z = skewed_loss(X, Y)plt.figure(figsize=(10, 6))plt.contour(X, Y, Z, levels=20, cmap='viridis')plt.plot(path_x, path_y, 'r-o', label="SGD Path")plt.title("SGD Struggling in an Ill-Conditioned Valley")plt.xlabel("X (Steep Direction)")plt.ylabel("Y (Flat Direction)")plt.legend()plt.axis('equal')plt.show()
Notice the zig-zag. The optimizer is fighting the geometry. To stop the bouncing in x, we’d have to lower the Learning Rate. But if we lower the LR, progress in y becomes glacial.
2. Feature Scaling: The First Fix
The simplest fix is one you learn in any introductory Machine Learning course: Standardize your data.
If we scale the inputs so they have unit variance (x' = x / \sigma), we essentially squash the ellipse into a circle.
Mathematically, this improves the Condition Number of the Hessian matrix. Consider a linear regression problem y = Xw. The Hessian is H = X^T X. The closer X is to orthogonal (spherical), the closer H is to the identity matrix, and the faster Gradient Descent converges.
But here is the catch: Standardizing the input dataset (X) only fixes the condition number for the first layer of weights. What about the rest of the network?
3. Batch Norm: Dynamic Conditioning
In a deep model (or any chained optimization problem), the “inputs” to Layer 2 are the “outputs” of Layer 1. h_1 = \sigma(W_1 x) h_2 = \sigma(W_2 h_1)
As the optimizer updates W_1, the distribution of h_1 shifts wildly. This means Layer 2 is constantly trying to chase a moving target. Its “input features” are changing scale and shape every iteration. This is called Internal Covariate Shift.
Batch Normalization (BN) is simply the idea of applying that “Standardization Fix” continuously throughout the optimization graph.
The Mechanism
Before passing the data to the next layer, we force it to be spherical. For every mini-batch B: \mu_B = \text{mean}(h_1) \quad \sigma_B^2 = \text{variance}(h_1) \hat{h}_1 = \frac{h_1 - \mu_B}{\sqrt{\sigma_B^2 + \epsilon}}
How it changes Optimization Geometry
It is not just about “better features”. It is a geometric transformation of the optimization landscape itself.
By forcing every intermediate signal to have mean 0 and variance 1:
Sphering: We force the “Ellipses” to stay “Circular” locally at every layer.
Length Independence: Scaling the weights by a constant factor (2W) effectively cancels out because the normalization divides by that same factor. This removes the “scale” degree of freedom, simplifying the search space.
Stability: We can use massive Learning Rates without bouncing off the walls, because the landscape no longer has “sharp, steep walls” caused by exploding signal variance.
Code
# Simple Visualization of Covariate Shift vs Batch Norm# We generate random data that drifts over time (Shift)# And show how BN keeps it stable.np.random.seed(42)steps =50means = np.linspace(-2, 2, steps)stds = np.linspace(0.5, 3.0, steps)raw_data = []bn_data = []for m, s inzip(means, stds):# Simulate the "Shift": Distribution changes every step batch = np.random.normal(m, s, 1000) raw_data.append(batch)# Apply Batch Norm Fix bn_batch = (batch - np.mean(batch)) / np.std(batch) bn_data.append(bn_batch)# Plotting the Distributions over time (Boxplots)fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))ax1.boxplot(raw_data[::5], positions=np.arange(0, 50, 5))ax1.set_title("Internal Covariate Shift (Raw Inputs to Layer L)")ax1.set_xlabel("Training Iteration")ax1.set_ylabel("Activation Value")ax1.set_ylim(-10, 10)ax2.boxplot(bn_data[::5], positions=np.arange(0, 50, 5))ax2.set_title("Stabilized Distribution (With Batch Norm)")ax2.set_xlabel("Training Iteration")ax2.set_ylim(-10, 10) # Same scaleplt.show()
Left (Raw): The inputs to the layer are drifting (mean moves up) and exploding (variance grows). The weights W_L have to constantly re-adapt to this changing scale.
Right (BN): The inputs are locked. They always look like a standard Gaussian. The weights W_L can focus purely on learning the pattern rather than chasing the scale.
4. Simulation: Conditioning the Landscape
Let’s simulate a deep linear chain (5 matrix multiplications) to optimize y = W_5 W_4 W_3 W_2 W_1 x.
This is a convex problem (it collapses to a single linear layer), but it is extremely ill-conditioned to train layer-by-layer. Only specific directions in the parameter space preserve the signal magnitude, while others kill it.
We will compare:
Standard SGD: Standard chain.
SGD + BatchNorm: We normalize the signal after every multiplication.
Code
# Simulation of Deep Linear Network signal propagation# We track the loss convergence to see if it vanishes/explodesdims =50depth =10batch_size =32np.random.seed(42)# Input Data & TargetX = np.random.randn(batch_size, dims)y_target = np.random.randn(batch_size, dims) # Dummy target# Initialize Weights (Same seeds)weights_std = [np.random.randn(dims, dims) *0.1for _ inrange(depth)]weights_bn = [w.copy() for w in weights_std] # Exact same start# SGD Settingslr =0.05losses_std = []losses_bn = []# Training Loopfor step inrange(200):# --- A. Standard Net Forward --- h = X.copy() cache_std = [h]for W in weights_std: h = h @ W cache_std.append(h) loss_std = np.mean((h - y_target)**2) losses_std.append(loss_std)# Standard Backward (Chain Rule) grad_h =2* (h - y_target) / batch_sizefor i inreversed(range(depth)): W = weights_std[i] h_prev = cache_std[i] dW = h_prev.T @ grad_h grad_h = grad_h @ W.T # Pass grad back weights_std[i] -= lr * dW# --- B. BN Net Forward ---# Concept: h = Norm(h @ W) h_bn = X.copy() cache_bn = [h_bn]for i, W inenumerate(weights_bn): z = h_bn @ W# Batch Norm Logic applied manually mean = np.mean(z, axis=0) std = np.std(z, axis=0) +1e-8 h_bn = (z - mean) / std # Normalize! cache_bn.append(h_bn) loss_bn = np.mean((h_bn - y_target)**2) losses_bn.append(loss_bn)# BN Backward (Approximate for demonstration)# We essentially update weights using the "Normalized" signal gradient.# This captures the main benefit: the GRADIENT scale is stabilized. grad_h_bn =2* (h_bn - y_target) / batch_sizefor i inreversed(range(depth)): W = weights_bn[i] h_prev = cache_bn[i] dW = h_prev.T @ grad_h_bn grad_h_bn = grad_h_bn @ W.T weights_bn[i] -= lr * dW # Same LR!plt.figure(figsize=(10, 6))plt.plot(losses_std, label="Standard Chains (Ill-Conditioned)", linestyle='--', color='blue')plt.plot(losses_bn, label="Conditioned Chains (Batch Norm)", linewidth=2, color='orange')plt.title("Optimizing a Deep Linear Chain ($W_{10}...W_1 x$)")plt.xlabel("Iteration")plt.ylabel("MSE Loss (Log Scale)")plt.legend()plt.yscale('log')plt.grid(True, alpha=0.3)plt.show()
/home/kmcalist/.local/lib/python3.10/site-packages/numpy/core/_methods.py:176: RuntimeWarning:
overflow encountered in multiply
/tmp/ipykernel_2571803/3313781696.py:73: RuntimeWarning:
overflow encountered in matmul
/tmp/ipykernel_2571803/3313781696.py:30: RuntimeWarning:
overflow encountered in matmul
/tmp/ipykernel_2571803/3313781696.py:30: RuntimeWarning:
invalid value encountered in matmul
The Standard Optimization (Blue) struggles. The chain of multiplications creates an ill-conditioned landscape where gradients are extremely sensitive. It gets stuck at a high loss.
The Conditioned Optimization (Orange) drops like a rock. By keeping the signal “spherical” (normalized) at every step, we effectively precondition the problem, making SGD incredibly efficient even through 10 layers.
For modern problems that have a long chain of gradients that are multiplied to compute downstream gradients, batch norm isn’t just a suggestion - it’s a necessity.
---title: "16: Conditioning the Landscape (Batch Norm)"format: html: code-fold: truejupyter: python3---## The Landscape Geometry ProblemSo far, we have discussed "Sharp vs Flat" minima. But there is another geometric feature that ruins SGD's day: **Eccentricity**.Ideally, we want our loss function to look like a perfect bowl (Circle).* Gradient points directly to the center.* We can take a straight path to the minimum.In reality, most loss functions are **elongated valleys (Ellipses)**.* Steep in one direction (Walls of the valley).* Flat in the other direction (Floor of the valley).### The "Bouncing" ProblemBecause the gradient corresponds to steepness, it is massive in the "steep" direction and tiny in the "flat" direction.* SGD takes a huge step into the wall, overshoots, and bounces back.* It barely moves along the valley floor.This zig-zagging behavior makes convergence painfully slow.---## 1. Visualizing Ill-ConditioningLet's visualize SGD trying to optimize a skewed quadratic bowl: $f(x,y) = 10x^2 + y^2$.* This valley is 10x steeper in $x$ than $y$.* The **Condition Number** (Ratio of curvature) is 10.```{python}import numpy as npimport matplotlib.pyplot as pltdef skewed_loss(x, y):return20*x**2+ y**2# Very steep in X, flat in Ydef skewed_grad(x, y):return np.array([40*x, 2*y])# Run SGDlr =0.04# If we set this for the flat direction (y), x will explode!w = np.array([1.5, 1.5])path_x, path_y = [w[0]], [w[1]]for _ inrange(20): g = skewed_grad(w[0], w[1]) w = w - lr * g path_x.append(w[0]) path_y.append(w[1])# Plotx_grid = np.linspace(-2, 2, 100)y_grid = np.linspace(-2, 2, 100)X, Y = np.meshgrid(x_grid, y_grid)Z = skewed_loss(X, Y)plt.figure(figsize=(10, 6))plt.contour(X, Y, Z, levels=20, cmap='viridis')plt.plot(path_x, path_y, 'r-o', label="SGD Path")plt.title("SGD Struggling in an Ill-Conditioned Valley")plt.xlabel("X (Steep Direction)")plt.ylabel("Y (Flat Direction)")plt.legend()plt.axis('equal')plt.show()```Notice the zig-zag. The optimizer is fighting the geometry. To stop the bouncing in $x$, we'd have to lower the Learning Rate. But if we lower the LR, progress in $y$ becomes glacial.---## 2. Feature Scaling: The First FixThe simplest fix is one you learn in any introductory Machine Learning course: **Standardize your data**.If we scale the inputs so they have unit variance ($x' = x / \sigma$), we essentially squash the ellipse into a circle.Mathematically, this improves the **Condition Number** of the Hessian matrix. Consider a linear regression problem $y = Xw$. The Hessian is $H = X^T X$. The closer $X$ is to orthogonal (spherical), the closer $H$ is to the identity matrix, and the faster Gradient Descent converges.**But here is the catch**: Standardizing the *input dataset* ($X$) only fixes the condition number for the **first layer** of weights. What about the rest of the network?---## 3. Batch Norm: Dynamic ConditioningIn a deep model (or any chained optimization problem), the "inputs" to Layer 2 are the "outputs" of Layer 1.$$ h_1 = \sigma(W_1 x) $$$$ h_2 = \sigma(W_2 h_1) $$As the optimizer updates $W_1$, the distribution of $h_1$ shifts wildly.This means Layer 2 is constantly trying to chase a moving target. Its "input features" are changing scale and shape every iteration. This is called **Internal Covariate Shift**.**Batch Normalization (BN)** is simply the idea of applying that "Standardization Fix" **continuously throughout the optimization graph**.### The MechanismBefore passing the data to the next layer, we force it to be spherical.For every mini-batch $B$:$$ \mu_B = \text{mean}(h_1) \quad \sigma_B^2 = \text{variance}(h_1) $$$$ \hat{h}_1 = \frac{h_1 - \mu_B}{\sqrt{\sigma_B^2 + \epsilon}} $$### How it changes Optimization GeometryIt is not just about "better features". It is a geometric transformation of the optimization landscape itself.By forcing every intermediate signal to have mean 0 and variance 1:1. **Sphering**: We force the "Ellipses" to stay "Circular" locally at every layer.2. **Length Independence**: Scaling the weights by a constant factor ($2W$) effectively cancels out because the normalization divides by that same factor. This removes the "scale" degree of freedom, simplifying the search space.3. **Stability**: We can use **massive Learning Rates** without bouncing off the walls, because the landscape no longer has "sharp, steep walls" caused by exploding signal variance.```{python}# Simple Visualization of Covariate Shift vs Batch Norm# We generate random data that drifts over time (Shift)# And show how BN keeps it stable.np.random.seed(42)steps =50means = np.linspace(-2, 2, steps)stds = np.linspace(0.5, 3.0, steps)raw_data = []bn_data = []for m, s inzip(means, stds):# Simulate the "Shift": Distribution changes every step batch = np.random.normal(m, s, 1000) raw_data.append(batch)# Apply Batch Norm Fix bn_batch = (batch - np.mean(batch)) / np.std(batch) bn_data.append(bn_batch)# Plotting the Distributions over time (Boxplots)fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))ax1.boxplot(raw_data[::5], positions=np.arange(0, 50, 5))ax1.set_title("Internal Covariate Shift (Raw Inputs to Layer L)")ax1.set_xlabel("Training Iteration")ax1.set_ylabel("Activation Value")ax1.set_ylim(-10, 10)ax2.boxplot(bn_data[::5], positions=np.arange(0, 50, 5))ax2.set_title("Stabilized Distribution (With Batch Norm)")ax2.set_xlabel("Training Iteration")ax2.set_ylim(-10, 10) # Same scaleplt.show()```- **Left (Raw)**: The inputs to the layer are drifting (mean moves up) and exploding (variance grows). The weights $W_L$ have to constantly re-adapt to this changing scale.- **Right (BN)**: The inputs are locked. They always look like a standard Gaussian. The weights $W_L$ can focus purely on learning the pattern rather than chasing the scale.---## 4. Simulation: Conditioning the LandscapeLet's simulate a deep linear chain (5 matrix multiplications) to optimize $y = W_5 W_4 W_3 W_2 W_1 x$.This is a convex problem (it collapses to a single linear layer), but it is **extremely ill-conditioned** to train layer-by-layer. Only specific directions in the parameter space preserve the signal magnitude, while others kill it.We will compare:1. **Standard SGD**: Standard chain.2. **SGD + BatchNorm**: We normalize the signal after every multiplication.```{python}# Simulation of Deep Linear Network signal propagation# We track the loss convergence to see if it vanishes/explodesdims =50depth =10batch_size =32np.random.seed(42)# Input Data & TargetX = np.random.randn(batch_size, dims)y_target = np.random.randn(batch_size, dims) # Dummy target# Initialize Weights (Same seeds)weights_std = [np.random.randn(dims, dims) *0.1for _ inrange(depth)]weights_bn = [w.copy() for w in weights_std] # Exact same start# SGD Settingslr =0.05losses_std = []losses_bn = []# Training Loopfor step inrange(200):# --- A. Standard Net Forward --- h = X.copy() cache_std = [h]for W in weights_std: h = h @ W cache_std.append(h) loss_std = np.mean((h - y_target)**2) losses_std.append(loss_std)# Standard Backward (Chain Rule) grad_h =2* (h - y_target) / batch_sizefor i inreversed(range(depth)): W = weights_std[i] h_prev = cache_std[i] dW = h_prev.T @ grad_h grad_h = grad_h @ W.T # Pass grad back weights_std[i] -= lr * dW# --- B. BN Net Forward ---# Concept: h = Norm(h @ W) h_bn = X.copy() cache_bn = [h_bn]for i, W inenumerate(weights_bn): z = h_bn @ W# Batch Norm Logic applied manually mean = np.mean(z, axis=0) std = np.std(z, axis=0) +1e-8 h_bn = (z - mean) / std # Normalize! cache_bn.append(h_bn) loss_bn = np.mean((h_bn - y_target)**2) losses_bn.append(loss_bn)# BN Backward (Approximate for demonstration)# We essentially update weights using the "Normalized" signal gradient.# This captures the main benefit: the GRADIENT scale is stabilized. grad_h_bn =2* (h_bn - y_target) / batch_sizefor i inreversed(range(depth)): W = weights_bn[i] h_prev = cache_bn[i] dW = h_prev.T @ grad_h_bn grad_h_bn = grad_h_bn @ W.T weights_bn[i] -= lr * dW # Same LR!plt.figure(figsize=(10, 6))plt.plot(losses_std, label="Standard Chains (Ill-Conditioned)", linestyle='--', color='blue')plt.plot(losses_bn, label="Conditioned Chains (Batch Norm)", linewidth=2, color='orange')plt.title("Optimizing a Deep Linear Chain ($W_{10}...W_1 x$)")plt.xlabel("Iteration")plt.ylabel("MSE Loss (Log Scale)")plt.legend()plt.yscale('log')plt.grid(True, alpha=0.3)plt.show()```- The Standard Optimization (Blue) struggles. The chain of multiplications creates an ill-conditioned landscape where gradients are extremely sensitive. It gets stuck at a high loss.- The Conditioned Optimization (Orange) drops like a rock. By keeping the signal "spherical" (normalized) at every step, we effectively precondition the problem, making SGD incredibly efficient even through 10 layers.For modern problems that have a long chain of gradients that are multiplied to compute downstream gradients, batch norm isn't just a suggestion - **it's a necessity**.