Making it fast
Kill the loops, stop copying, reuse buffers — in that order.
out=in-place opsnp.vectorizetemporarieswhere=contiguityfloat32Watch it happen
Play it through, or step back and forth yourself.
Doubling a million values four ways. The gap between the Python loop and the vectorised expression isn't a few percent — it's roughly fifty times, and it widens with size.
The idea
This lesson pulls together strides from lesson 4, views from lesson 6 and vectorisation from lesson 14 into one practical question: when NumPy is slower than you expected, what do you actually do about it?
The honest answer is that there are four levers and they are wildly unequal. Work down the list in order.
1. Remove Python loops
This is the only order-of-magnitude win, and everything else is rounding error next to it. Roughly fifty times, and it grows with array size.
A warning about a function that sounds like the solution and isn't:
np.vectorize(f)(a) # NOT faster — it loops in Python underneathNumPy's own documentation calls it a convenience function and says it's provided primarily for readability, not performance. It's genuinely useful for making a scalar function accept arrays and broadcast properly — it just won't make anything quick. If you need a real speed-up for logic that can't be expressed with ufuncs, that's what Numba or Cython are for.
2. Stop copying
Every arithmetic expression allocates. a + b + c looks like one pass and is two: a whole temporary array for a + b, then the result.
d = a + b + c # two allocations
d = a + b; d += c # oneMasks copy too. a[a > 0].sum() builds an entire filtered array just to add it up and throw it away. Most reductions take a where= instead:
a[a > 0].sum() # allocates a filtered copy
a.sum(where=a > 0) # same answer, no copy3. Reuse buffers
np.add(a, b, out=a) # write into memory you already have
np.sqrt(a, out=a) # most ufuncs take out=
a += b # the operator form of the same thing
a *= 2In a loop that runs thousands of times over large arrays, allocating a fresh output every iteration is real cost — both the allocation and the cache pressure. Pre-allocate once with np.empty_like(a) and write into it.
The catch is lesson 6: in-place writes hit every view of that memory. If someone else is holding a slice, you've just changed their data too.

4. Mind the layout
CPUs read memory in cache lines, so walking forwards is much faster than jumping about. On a C-ordered array, a.sum(axis=1) walks along each row contiguously; a.sum(axis=0) steps a whole row's width each time.
a.flags['C_CONTIGUOUS'] # check before blaming the algorithm
np.ascontiguousarray(a) # pay the copy once, outside the loopAnd dtype is a lever too: float32 is half the bytes of float64, so it moves through cache twice as fast. If you don't need sixteen digits of precision — and for most machine learning you don't — that's a free win.

Measure first
%timeit a * 2 # in a notebook
from time import perf_counter # anywhere else
t = perf_counter(); f(); perf_counter() - tIntuitions about what's slow are usually wrong, and the fix is often somewhere you weren't looking. Measure, change one thing, measure again. Optimising code that wasn't the bottleneck is the most common way to waste an afternoon.
Practice
Write it yourself. The answer is there when you want it.
Putting the kettle on…
Starting up…
Write it yourself
not gradedMake a million floats with np.arange. Time a list comprehension that doubles them, then time big * 2, using perf_counter from time. Print both in milliseconds and the speed-up between them. Finish with big.sum(where=big > 500).
Your turn
3 exercises. Write the code yourself, then press Check — a nudge and the answer are there if you want them.
Sum only the values of cups above 100, using where= instead of building a filtered copy.
Return cups converted to float32, then its nbytes — half what float64 would take.
Double every value of a copy of v in place, using np.multiply with out=, and return it.
