NumPy Broadcasting
Understand NumPy's broadcasting rules and write vectorized operations across arrays of different shapes without writing loops.
Real-World Scenario
A data scientist needs to normalize a dataset of 10,000 samples by subtracting the mean and dividing by the standard deviation for each of 50 features. Without broadcasting, this requires a Python loop over 50 features. With broadcasting, it’s two lines of NumPy that run in C — the standard pattern for feature normalization in every ML pipeline.
The Broadcasting Rules
NumPy compares shapes element-by-element, starting from the trailing dimensions:
- If the arrays have different numbers of dimensions, prepend
1s to the shape of the smaller array. - Arrays with size
1along a dimension are stretched to match the other array in that dimension. - If shapes don’t match and neither is
1, NumPy raises aValueError.
Array A: (4, 3)
Array B: (3,) → treated as (1, 3) → stretched to (4, 3)
Result: (4, 3)
Array A: (4, 1)
Array B: (3,) → treated as (1, 3)
Result: (4, 3)
Array A: (4, 3)
Array B: (4, 1)
Result: (4, 3)
Array A: (4, 3)
Array B: (3, 4) → mismatch, no size-1 dimension → ValueError
Example 1: Scalar Broadcasting
The simplest case — every operation between an array and a scalar broadcasts automatically. This is broadcasting even if it doesn’t feel like it.
import numpy as np
prices = np.array([9.99, 14.99, 24.99, 4.99])
# Scalar 1.08 broadcasts across the entire array
with_tax = prices * 1.08
print(with_tax) # [10.7892 16.1892 26.9892 5.3892]
# Same for comparison
is_expensive = prices > 10.0
print(is_expensive) # [False True True False]
Example 2: Row Vector Broadcasting
Subtracting a 1-D array from a 2-D array — the most common broadcasting pattern in ML preprocessing.
import numpy as np
rng = np.random.default_rng(42)
# Simulate a dataset: 5 samples, 3 features
data = rng.standard_normal((5, 3))
print("Data shape:", data.shape) # (5, 3)
# Column means — one value per feature
col_means = data.mean(axis=0)
print("Means shape:", col_means.shape) # (3,) → broadcasts as (1, 3)
# Subtract the mean from every row — zero-centers each feature
centered = data - col_means # (5, 3) - (3,) → (5, 3)
print("Centered means:", centered.mean(axis=0).round(10)) # ~[0. 0. 0.]
# Divide by std — standardizes each feature to unit variance
col_stds = data.std(axis=0)
normalized = centered / col_stds # (5, 3) / (3,)
print("Normalized std:", normalized.std(axis=0).round(6)) # [1. 1. 1.]
Example 3: Column Vector Broadcasting
Using np.newaxis to broadcast a 1-D array as a column.
import numpy as np
# Row vector: shape (4,) — represents 4 columns
row = np.array([1, 2, 3, 4])
# Column vector: shape (3, 1) — represents 3 rows
col = np.array([10, 20, 30])[:, np.newaxis] # reshape (3,) → (3, 1)
print("row shape:", row.shape) # (4,)
print("col shape:", col.shape) # (3, 1)
# Broadcasting rules:
# row → treated as (1, 4) → stretched to (3, 4)
# col → stretched from (3, 1) to (3, 4)
result = row + col
print(result)
# [[11 12 13 14]
# [21 22 23 24]
# [31 32 33 34]]
Example 4: Pairwise Distance Matrix
A classic broadcasting pattern: compute all pairwise distances between two sets of points without any Python loops.
import numpy as np
# 5 points in 2-D space
points = np.array([
[0., 0.],
[1., 0.],
[0., 1.],
[1., 1.],
[0.5, 0.5]
])
# points[:, np.newaxis] → shape (5, 1, 2)
# points[np.newaxis, :] → shape (1, 5, 2)
# difference → shape (5, 5, 2) — all pairwise differences
diff = points[:, np.newaxis, :] - points[np.newaxis, :, :]
# Euclidean distance = sqrt(sum of squared differences)
distances = np.sqrt((diff ** 2).sum(axis=-1)) # (5, 5)
print(distances.round(3))
# [[0. 1. 1. 1.414 0.707]
# [1. 0. 1.414 1. 0.707]
# ...
Real-World: Z-Score Normalization at Scale
import numpy as np
rng = np.random.default_rng(0)
X = rng.standard_normal((10_000, 50)) # 10k samples, 50 features
# These are shape (50,) — one value per feature
mean = X.mean(axis=0)
std = X.std(axis=0)
# Broadcasting: (10000, 50) - (50,) and / (50,) — runs in C, no Python loop
X_normalized = (X - mean) / std
print(X_normalized.mean(axis=0).max()) # ~0.0 — all feature means near 0
print(X_normalized.std(axis=0).min()) # ~1.0 — all feature stds near 1
Common Mistakes
1. Forgetting to add an axis for column broadcasting:
import numpy as np
A = np.ones((4, 3))
b = np.array([1, 2, 3, 4]) # shape (4,) — intended as a column
# Wrong: (4, 3) - (4,) tries to broadcast trailing dims (3 vs 4) → ValueError
# A - b
# Correct: reshape b to (4, 1) to broadcast as a column
A - b[:, np.newaxis] # (4, 3) - (4, 1) → (4, 3)
2. Assuming broadcasting creates a copy:
# Broadcasting computes a new array — it does not mutate either input
result = A + b[np.newaxis, :] # new array, A and b are unchanged
3. Confusing axis in reductions:
import numpy as np
X = np.ones((5, 3))
print(X.mean(axis=0).shape) # (3,) — mean across rows, one value per column
print(X.mean(axis=1).shape) # (5,) — mean across columns, one value per row