NumPy arrays: why one line beats a loop by a hundred times
Two ways to hold a million numbers
nums = [float(i) for i in range(1_000_000)]A Python list is a row of pointers. Each pointer leads to a separate float object somewhere in memory, and each float object carries a type tag, a reference count and the value — 24 bytes for an 8-byte number. Squaring them means, for each element: follow the pointer, check that the object is a float, extract the value, compute, allocate a new float object, store the pointer. A million times.
import numpy as np
arr = np.arange(1_000_000, dtype=np.float64)A NumPy array is one contiguous block of raw bytes, 8 million of them here, with a single header saying "these are float64, there are a million of them". Squaring means a compiled C loop walking that block, no type checks, no allocation per element, and the CPU's own vector instructions doing several at a time.
Measure it:
%timeit [x * x for x in nums] # ~120 ms
%timeit arr ** 2 # ~1.5 msThat ratio is the reason NumPy exists, and the reason every numeric library in Python — pandas, scikit-learn, PyTorch's CPU tensors — is built on it or mirrors it. It is not multithreading. It is not magic. It is memory layout plus compiled loops, and understanding that tells you when it will and will not help.
Making arrays
np.array([1, 2, 3]) # from a list
np.zeros(5) # [0., 0., 0., 0., 0.]
np.ones((2, 3)) # 2 rows, 3 columns of 1.
np.arange(0, 10, 2) # [0, 2, 4, 6, 8]
np.linspace(0, 1, 5) # [0., 0.25, 0.5, 0.75, 1.]
np.random.default_rng(0).random(4) # 4 floats in [0, 1), seededThe seed matters. default_rng(0) gives the same "random" numbers every run, which is what you want when a result has to be reproducible. Unseeded, a test that passes today fails tomorrow for no reason you can find.
Every array has three attributes worth printing before you do anything with it:
arr.shape # (1000000,)
arr.dtype # float64
arr.ndim # 1Vectorised operations
Arithmetic applies elementwise, and so do comparisons and most maths functions:
a = np.array([1., 2., 3.])
b = np.array([10., 20., 30.])
a + b # [11., 22., 33.]
a * 2 # [2., 4., 6.]
a > 1.5 # [False, True, True]
np.sqrt(a) # elementwise square root
np.exp(a) # elementwise e^xnp.sqrt(a) on an array is not the same as math.sqrt(a), which expects one number and raises on an array. The NumPy versions — called ufuncs — accept arrays and broadcast, which the next lesson covers.
Reductions collapse an array to a number:
a.sum(), a.mean(), a.max(), a.argmax(), a.std()argmax returns the index of the largest value rather than the value — the thing you want when the array is scores and you need to know which item won.
The loop you should not write
total = 0
for x in arr: # slow: pulls each element out as a Python float
total += xIterating over a NumPy array in Python throws away the advantage; each element is boxed back into a Python object on the way out. If you find yourself writing for i in range(len(arr)), stop and ask what the whole-array expression is. Usually it exists: a comparison, a where, a reduction, a dot product. The lesson on masks shows the common ones.
When it genuinely does not exist — the computation depends on the previous element in a way no ufunc expresses — the honest options are numba (a free just-in-time compiler that makes such loops fast with one decorator) or accepting the loop for a small array.
When NumPy does not help
- Small arrays. For ten numbers, the overhead of calling into C exceeds the loop. NumPy wins from a few hundred elements up.
- Mixed types. An array holds one dtype. A column of names and prices is not an array; that is pandas' job.
- Growing one element at a time.
np.appendcopies the whole array each call, so building an array by appending in a loop is quadratic. Collect in a Python list, thennp.array(list)once at the end. - Objects.
np.array(["a", "b"])works but gives you fixed-width strings orobjectdtype, and object arrays are Python lists in a NumPy coat — no speed-up.
Where this goes
Every embedding you fetch from a model is an array of floats, 384 to 3,072 of them. Every batch of them is a two-dimensional array. Similarity search is a matrix multiplication over that array, and the reason it runs over a hundred thousand vectors in milliseconds is exactly the layout described above. Two lessons from now you will write it.
Try this now
Build the million-element list and array, time squaring each with %timeit or timeit, and write the ratio down. Then time sum(nums) against arr.sum(), and a Python for loop over arr against arr.sum(), to see what iterating an array in Python costs.
The one thing to keep
A NumPy array is one block of memory holding values of a single type, so an operation on it is a compiled loop over raw numbers; a Python list is a row of pointers to separate objects, and the loop over it pays for type checks and allocation on every element.
Before you move on
Squaring a million numbers takes 120 ms as a Python list comprehension and 1.5 ms as `arr ** 2` on a NumPy array. A learner explains this as "NumPy uses all the CPU cores". What is the more accurate account?
Pick the one you would defend. Nobody sees your answer.