3.1 NumPy: arrays, broadcasting, vectorization

Checked against the NumPy user guide, August 2026

What this is and why it exists

NumPy arrays are the substrate of every Python data library you will use: pandas, scikit-learn and PyTorch are all built on the same idea of a typed block of numbers with a shape. Learn the array model once and three libraries stop being mysterious. This topic also holds the single largest performance win available to a beginner — replacing a Python loop over numbers with one array operation.

The vocabulary

  • ndarray — a block of values, all of one type, with a shape.
  • dtype — that type: 64-bit float, 32-bit integer, boolean.
  • shape — the size along each dimension, as a tuple.
  • axis — one dimension; axis 0 runs down the rows, axis 1 across the columns.
  • View — an array sharing memory with another, so changing one changes both.
  • Fancy indexing — selecting with a list of positions, which produces a copy rather than a view.
  • Boolean mask — an array of true and false, used to select the true positions.
  • Broadcasting — the rules that let arrays of different shapes be combined.
  • Seed — the number that makes a sequence of random values reproducible.

The mental model

An array is not a list of numbers; it is one typed block plus a description of how to read it. That is why arrays are fast — the values sit together in memory and the operation runs in compiled code — and it is why the dtype matters: a float array and an integer array of the same numbers behave differently under division, and an array that has quietly become the general object type has lost all of the speed.

Vectorisation follows directly. The documentation puts it plainly: broadcasting "provides a means of vectorizing array operations so that looping occurs in C instead of Python. It does this without making needless copies of data and usually leads to efficient algorithm implementations." So when you write an operation over a whole array, the loop still happens — it happens somewhere much faster than your for statement. If you are writing a Python loop over array elements, you are almost certainly leaving a large factor on the table.

Broadcasting is the rule set that decides which shapes can be combined, and it repays learning precisely rather than by trial and error, because the error message otherwise looks arbitrary. The documentation is exact: "NumPy compares their shapes element-wise. It starts with the trailing (i.e. rightmost) dimension and works its way left. Two dimensions are compatible when they are equal, or one of them is 1." Anything else raises the operands-could-not-be-broadcast error.

Read the rule right to left and most shape confusion evaporates. Subtracting a row of column means from a whole data matrix works because the row's shape lines up with the matrix's last dimension. Subtracting a column of row means does not, until you give it a second dimension of size one so that the rules have something to stretch — which is what the "add an axis" idiom is for, and why it appears everywhere in machine-learning code.

Indexing comes in three kinds and the difference matters for correctness, not only speed. A basic slice returns a view — it shares memory, so writing to it writes to the original. Fancy indexing with a list of positions, and boolean masking, both return copies. That is a real distinction: modify a slice and the parent changes; modify the result of a mask and it does not.

Finally, reproducibility. Random numbers in a data project must be reproducible or none of your comparisons mean anything: two models evaluated on differently-shuffled splits are not being compared. Create a generator with an explicit seed, pass it where randomness is needed, and record the seed with the result. And be honest that a seed makes a run repeatable, not correct — a result that only holds for one seed is a result about that seed.

In code

Checked against the NumPy user guide.

import numpy as np

rng = np.random.default_rng(seed=20260827)   # reproducible, and recorded

x = rng.normal(size=(1000, 3))
print(x.shape, x.dtype)          # (1000, 3) float64

# Vectorised: one operation over the whole array, looping in compiled code.
column_means = x.mean(axis=0)    # shape (3,)
centred = x - column_means       # broadcasting: (1000, 3) against (3,)

row_means = x.mean(axis=1)       # shape (1000,)
# centred_rows = x - row_means   # ValueError: shapes do not line up
centred_rows = x - row_means[:, np.newaxis]   # (1000, 3) against (1000, 1)

# Boolean masking returns a copy; a slice returns a view.
big = x[x[:, 0] > 2]
view = x[:10]
view[0, 0] = 999                 # this writes through to x

Read the two centring lines together, because they are the whole broadcasting lesson. Column means have shape three, which lines up with the array's trailing dimension, so it works. Row means have shape one thousand, which lines up with nothing, so it fails — until the extra axis makes the shape one thousand by one, and the rule that a dimension of one may be stretched does the rest.

What you should now be able to explain or do

Say what an array is and why the dtype affects both correctness and speed. Replace a Python loop over numbers with an array operation and measure the difference. State the broadcasting rule from the trailing dimension inward, and use it to explain a shape error you have met. Add an axis to make a column vector broadcast down the rows. Say which kinds of indexing return a view and which return a copy, and why that changes behaviour. Seed a generator and say what a seed does and does not guarantee.

Check yourself

Because the loop happens in compiled code over values stored together in memory, rather than one interpreted step per element. The documentation describes broadcasting as vectorising operations "so that looping occurs in C instead of Python".

Compare shapes from the rightmost dimension leftwards. Two dimensions are compatible when they are equal, or when one of them is 1. Anything else is an error.

Column means line up with the trailing dimension; row means line up with nothing. Adding an axis makes the row means a column of shape n by 1, and the size-1 rule lets it stretch across the columns.

Expected. Fancy indexing and boolean masks return copies; a basic slice returns a view that shares memory. Knowing which you have is a correctness question, not a performance one.

That the same code produces the same sequence again, so comparisons are meaningful and results are reproducible. It does not make a result correct — one that holds only for a single seed is a result about that seed.

Go deeper

Back to NumPy: arrays, broadcasting, vectorization: work through the checklist