PYTH-250 · Lesson 9 of 12 · 22 min

NumPy for fast numeric code

▶ Video coming soon A video version of this lesson is on the way.

Processes gave Kai's roast-curve analysis eight cores, but each core is still running slow Python loops over hundreds of thousands of temperature readings. Python loops are slow for a specific reason: every number is a full Python object, and every addition in a loop means the interpreter looking up types, checking for overflow and creating a new object. For numeric work there is a far better approach than spreading those loops over more cores: stop looping in Python at all, and hand whole arrays of numbers to code written in C that processes them in one go.

That is what NumPy does. It is the foundation of almost all scientific and numeric Python, from pandas to machine learning libraries. This lesson introduces the NumPy array, vectorized arithmetic that replaces explicit loops, aggregations along axes, boolean masks for filtering, broadcasting, and a few tools such as np.diff, np.convolve and np.where that turn Kai's per-reading loops into single expressions. NumPy runs in the browser here: the first run that uses it loads the library, which takes a few extra seconds.

Why Python loops are slow for numbers

A Python list of numbers is really a list of pointers to separate number objects scattered around memory, each carrying its type and reference count. A NumPy array is one contiguous block of raw numbers of a single type, such as 64-bit floats, with a small header describing its shape. Operations on arrays run in compiled C loops over that block, with no per-element interpreter work. The difference is commonly a factor of ten to a hundred for simple arithmetic, and it grows for more complex operations.

A Python loop against one vectorized NumPy expression (output from one laptop; times vary)
import time
import numpy as np

n = 300_000
temps_list = [180 + (i % 840) * 0.05 for i in range(n)]       # a week of readings
temps = np.array(temps_list)

start = time.perf_counter()
fahrenheit_list = [t * 9 / 5 + 32 for t in temps_list]
t_loop = time.perf_counter() - start

start = time.perf_counter()
fahrenheit = temps * 9 / 5 + 32                                 # one vectorized expression
t_numpy = time.perf_counter() - start

print("same values:", np.allclose(fahrenheit, fahrenheit_list))
print(f"loop {t_loop * 1000:.1f} ms, NumPy {t_numpy * 1000:.2f} ms, about {t_loop / t_numpy:.0f}x faster")
Output
same values: True
loop 38.4 ms, NumPy 1.21 ms, about 32x faster

Creating arrays

np.array(sequence) builds an array from a list. Every element has the same data type, its dtype, chosen automatically or given explicitly. np.zeros, np.ones and np.full make arrays of a given shape filled with one value; np.arange works like range but returns an array; np.linspace(start, stop, num) gives evenly spaced values including both ends. An array's shape is a tuple of its sizes along each dimension: (840,) for one roast of 840 readings, (60, 840) for sixty roasts stored as rows.

Arrays have one dtype and a shape
import numpy as np

batch = np.array([182.0, 168.5, 160.1, 163.4, 171.2, 180.9])     # first six half-minute readings
print(batch, batch.dtype, batch.shape)
print(np.arange(0, 3, 0.5))
print(np.linspace(0, 14, 5))                 # minutes at five evenly spaced points
week = np.zeros((60, 840))                   # 60 roasts of 840 readings
print(week.shape, week.size, f"{week.nbytes:,} bytes")
Output
[182.  168.5 160.1 163.4 171.2 180.9] float64 (6,)
[0.  0.5 1.  1.5 2.  2.5]
[ 0.   3.5  7.  10.5 14. ]
(60, 840) 50400 403,200 bytes

Vectorized arithmetic

Arithmetic between an array and a number, or between two arrays of the same shape, works element by element without a loop. temps * 9 / 5 + 32 converts every reading; green - roasted subtracts matching elements of two arrays. Comparison operators produce arrays of True and False. NumPy also provides universal functions, ufuncs, such as np.sqrt, np.exp, np.log and np.round, that apply to every element. Writing code in this whole-array style is called vectorization.

Whole-array arithmetic replaces per-batch loops
import numpy as np

green_kg = np.array([12.0, 12.0, 11.5, 12.0, 10.0])
roasted_kg = np.array([10.15, 10.30, 9.70, 10.02, 8.61])

loss = (green_kg - roasted_kg) / green_kg           # element by element
bags = np.floor(roasted_kg / 0.34).astype(int)      # 340 g bags per batch
print("weight loss share:", np.round(loss, 3))
print("bags per batch:  ", bags, "total:", int(bags.sum()))
print("batches losing more than 0.16:", loss > 0.16)
Output
weight loss share: [0.154 0.142 0.157 0.165 0.139]
bags per batch:   [29 30 28 29 25] total: 141
batches losing more than 0.16: [False False False  True False]

Try it yourself

Vectorized weight loss

Write loss_shares(green, roasted) that takes two NumPy arrays of batch weights in kilograms and returns an array of the weight lost in roasting as a share of the green weight, rounded to 3 decimal places.

Then write bags(roasted, bag_kg=0.34) returning an integer array of how many whole bags each batch fills. Use array operations, not Python loops.

import numpy as np

def loss_shares(green, roasted):
    return [(g - r) / g for g, r in zip(green, roasted)]

def bags(roasted, bag_kg=0.34):
    return roasted

print(loss_shares(np.array([12.0]), np.array([10.2])))

Aggregations and axes

Aggregation functions reduce an array to fewer numbers: sum, mean, min, max, std, argmin and argmax, available as methods or as np functions. For a two-dimensional array they take an axis argument. axis=0 aggregates down the columns, giving one result per column; axis=1 aggregates across each row, giving one result per row. Kai stores a week of roasts as a two-dimensional array, one row per batch and one column per half-minute, so the peak temperature of every batch is a single expression.

axis=1 works along each row; axis=0 works down each column
import numpy as np

# 4 batches (rows) x 6 half-minute readings (columns)
curves = np.array([
    [182, 165, 158, 166, 179, 192],
    [185, 167, 160, 168, 181, 195],
    [180, 162, 155, 161, 173, 186],
    [184, 166, 159, 167, 180, 194],
], dtype=float)

print("peak per batch (axis=1):   ", curves.max(axis=1))
print("mean per reading (axis=0): ", curves.mean(axis=0))
print("turning point index per batch:", curves.argmin(axis=1))
print("hottest batch:", int(curves.max(axis=1).argmax()))
Output
peak per batch (axis=1):    [192. 195. 186. 194.]
mean per reading (axis=0):  [182.75 165.   158.   165.5  178.25 191.75]
turning point index per batch: [2 2 2 2]
hottest batch: 1

Boolean masks

A comparison such as temps > 196 produces a boolean array of the same shape. Using it as an index, temps[temps > 196], selects only the elements where the mask is True. Masks combine with & (and), | (or) and ~ (not), with parentheses around each comparison because of operator precedence. np.where(condition, a, b) builds a new array choosing from a where the condition holds and from b elsewhere, and np.count_nonzero or mask.sum() counts the True values.

Masks select, count and label elements without a loop
import numpy as np

temps = np.array([182, 165, 158, 166, 179, 192, 198, 203, 207, 211], dtype=float)
minutes = np.arange(len(temps)) * 0.5

print("readings above 196:", temps[temps > 196])
print("minutes in development window:", minutes[(temps >= 196) & (temps <= 208)])
print("count above 200:", int((temps > 200).sum()))
print("first reading at or above 196 is index", int(np.argmax(temps >= 196)))
print(np.where(temps >= 196, "crack zone", "drying").tolist()[-5:])
Output
readings above 196: [198. 203. 207. 211.]
minutes in development window: [3.  3.5 4. ]
count above 200: 3
first reading at or above 196 is index 6
['drying', 'crack zone', 'crack zone', 'crack zone', 'crack zone']

Try it yourself

Find first crack

First crack happens when the bean temperature first reaches about 196 degrees Celsius. Write first_reaching(temps, threshold) that returns the index of the first reading greater than or equal to threshold, or -1 if no reading reaches it.

Use a boolean mask with np.argmax or np.flatnonzero, not a Python loop. Careful: np.argmax of an all-False mask is 0.

import numpy as np

def first_reaching(temps, threshold):
    return int(np.argmax(temps >= threshold))

print(first_reaching(np.array([180.0, 190.0, 197.0]), 196))

Broadcasting

When two arrays of different shapes meet in an operation, NumPy tries to broadcast them: dimensions of size one are stretched to match the other array, without copying data. Subtracting a one-dimensional target profile of 840 readings from a (60, 840) array of roasts subtracts the target from every row. Dividing a (60, 840) array by a (60, 1) column of per-batch values divides each row by its own value. Broadcasting is what lets whole-dataset calculations stay one line long.

Broadcasting a target profile across every batch
import numpy as np

curves = np.array([[182, 165, 158, 166, 179, 192],
                   [185, 167, 160, 168, 181, 195],
                   [180, 162, 155, 161, 173, 186]], dtype=float)
target = np.array([183, 166, 158, 166, 178, 191], dtype=float)      # shape (6,)

deviation = curves - target                    # target is applied to every row
score = np.abs(deviation).mean(axis=1)         # mean absolute deviation per batch
print(deviation)
print("score per batch:", np.round(score, 2), "best batch:", int(score.argmin()))
per_batch_start = curves[:, :1]                # shape (3, 1)
print("rise from the start temperature:\n", curves - per_batch_start)
Output
[[-1. -1.  0.  0.  1.  1.]
 [ 2.  1.  2.  2.  3.  4.]
 [-3. -4. -3. -5. -5. -5.]]
score per batch: [0.67 2.33 4.17] best batch: 0
rise from the start temperature:
 [[  0. -17. -24. -16.  -3.  10.]
 [  0. -18. -25. -17.  -4.  10.]
 [  0. -18. -25. -19.  -7.   6.]]
A grid of 3 rows and 6 columns labeled curves, shape 3 by 6, minus a single row labeled target, shape 6. Dashed copies of the target row are drawn beneath the first, showing it stretched to 3 rows without copying data. The result is a 3 by 6 grid of deviations.
Broadcasting stretches the smaller array along the missing dimension

Rate of rise and smoothing

Roasters judge a roast by its rate of rise, how many degrees the beans gain per minute. With readings every 30 seconds, the rise between neighboring readings is np.diff(temps), and doubling it gives degrees per minute. Real sensor data is noisy, so the rate is smoothed with a moving average. A moving average of width k is a convolution with k equal weights: np.convolve(x, np.ones(k) / k, mode='valid') computes it for the whole array at once, which in a loop would be one Python iteration per reading.

np.diff and np.convolve compute the smoothed rate of rise for a whole roast
import numpy as np

temps = np.array([182, 165, 158, 160, 166, 172, 178, 183, 188, 192, 196, 199, 202], dtype=float)
ror = np.diff(temps) * 2                       # readings every 30 s -> degrees per minute
smooth = np.convolve(ror, np.ones(3) / 3, mode="valid")
print("rate of rise:", ror)
print("smoothed:    ", np.round(smooth, 1))
print("rate falling after the turning point:", bool(np.all(np.diff(smooth[3:]) <= 0)))
Output
rate of rise: [-34. -14.   4.  12.  12.  12.  10.  10.   8.   8.   6.   6.]
smoothed:     [-14.7   0.7   9.3  12.   11.3  10.7   9.3   8.7   7.3   6.7]
rate falling after the turning point: True

Try it yourself

Rate of rise

Readings are taken every interval seconds. Write rate_of_rise(temps, interval=30) returning a NumPy array of degrees gained per minute between neighboring readings: the differences multiplied by 60 / interval.

Then write smoothed(values, k) returning the moving average of width k with np.convolve(..., mode="valid"), rounded to 1 decimal place.

import numpy as np

def rate_of_rise(temps, interval=30):
    return np.diff(temps)

def smoothed(values, k):
    return values

print(rate_of_rise(np.array([160.0, 166.0, 172.0])))

Summaries: quantiles, sorting and counting

Kai rarely needs every reading; he needs summaries. NumPy computes them without loops. np.quantile(a, q) gives the value below which a share q of the data falls, so np.quantile(a, 0.9) is the value that nine readings in ten stay under, which is more robust to a few faulty sensor spikes than the maximum. np.sort returns a sorted copy and np.argsort the order that would sort it, useful for ranking batches. np.unique with return_counts=True counts how often each value occurs, the array version of Counter. And np.histogram sorts values into bins, the basis of any distribution chart.

Quantiles, ranking, counting and histograms on whole arrays
import numpy as np

roast_minutes = np.array([14.2, 12.8, 15.6, 13.1, 16.0, 14.4, 13.9, 14.1, 15.2, 13.5])
print("median:", round(float(np.median(roast_minutes)), 2), "| 0.9 quantile:", round(float(np.quantile(roast_minutes, 0.9)), 2))
order = np.argsort(roast_minutes)[::-1]            # longest first
print("longest three batches (indexes):", order[:3].tolist())

profiles = np.array(["light", "medium", "dark", "medium", "medium", "light", "dark", "medium"])
names, counts = np.unique(profiles, return_counts=True)
print(dict(zip(names.tolist(), counts.tolist())))

counts, edges = np.histogram(roast_minutes, bins=[12, 13, 14, 15, 16, 17])
for lo, n in zip(edges[:-1], counts):
    print(f"{lo:.0f}-{lo + 1:.0f} min: {'#' * int(n)}")
Output
median: 14.15 | 0.9 quantile: 15.64
longest three batches (indexes): [4, 2, 8]
{'dark': 2, 'light': 2, 'medium': 4}
12-13 min: #
13-14 min: ###
14-15 min: ###
15-16 min: ##
16-17 min: #

Converting results back with tolist() or int() before printing keeps the output plain and avoids NumPy's own number types appearing in lists and dictionaries, whose printed form has changed between NumPy versions.

When NumPy is the right tool

NumPy shines when the same operation applies to many numbers: sensor readings, prices, measurements, pixel values, anything that fits naturally in arrays. It also releases the global interpreter lock during most large operations, so it combines well with threads. It is not a speedup for code that works on small numbers of items, on strings and dictionaries, or with complicated branching per element; converting such data to arrays can cost more than it saves. When a calculation cannot be expressed with array operations, tools such as Numba, which compiles Python functions to machine code, or Cython fill the gap, but for most numeric work, thinking in whole arrays is enough. Kai's weekly analysis, rewritten with NumPy, runs in under a second on one core, so the process pool from lesson 5 is no longer needed at all, a reminder that a better algorithm or library often beats more hardware.

Common mistakes

  • MistakeLooping over a NumPy array element by element in Python.

    InsteadUse vectorized operations, ufuncs and aggregations on the whole array.

  • MistakeWriting temps > 190 and temps < 200 to combine masks.

    InsteadUse (temps > 190) & (temps < 200) with parentheses.

  • MistakeForgetting which axis is which.

    Insteadaxis=0 aggregates down columns; axis=1 aggregates across each row.

  • MistakeAssuming np.argmax(mask) finds nothing when no element matches.

    InsteadAn all-False mask gives 0; check mask.any() or use np.flatnonzero.

  • MistakeMixing lists and arrays and expecting arithmetic to work on lists.

    InsteadConvert with np.array first; list * 2 repeats the list.

  • MistakeConverting tiny or string-heavy data to arrays for speed.

    InsteadNumPy helps with large numeric arrays; for small or mixed data, plain Python may be faster.

Summary and key terms

  • Python loops over numbers are slow because each number is a separate object handled by the interpreter.
  • A NumPy array stores raw numbers of one dtype in one block, with a shape describing its dimensions.
  • Vectorized arithmetic and ufuncs apply operations to whole arrays in compiled code.
  • Aggregations such as sum, mean, max and argmin take an axis argument for two-dimensional data.
  • Boolean masks select and count elements; combine them with &, | and ~.
  • Broadcasting stretches arrays of compatible shapes so operations work across rows or columns.
  • np.diff, np.convolve and np.where turn common loops into single expressions.
NumPy array
A block of numbers of one type with a shape, processed by compiled code.
dtype
The data type of every element in an array, such as float64.
shape
A tuple giving an array's size along each dimension.
vectorization
Expressing a calculation as operations on whole arrays instead of loops.
ufunc
A universal function that applies element by element, such as np.sqrt.
axis
The dimension along which an aggregation is computed.
boolean mask
An array of True and False used to select elements.
broadcasting
Stretching arrays of different but compatible shapes to combine them.
moving average
The average of each run of k neighboring values.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. What does np.array([1, 2, 3]) * 2 give, and what does [1, 2, 3] * 2 give?

    Show answer

    array([2, 4, 6]); the list [1, 2, 3, 1, 2, 3].

  2. A (60, 840) array holds 60 roasts. How do you get each roast's peak?

    Show answer

    curves.max(axis=1), giving 60 values.

  3. Select readings between 196 and 208 inclusive.

    Show answer

    temps[(temps >= 196) & (temps <= 208)]

  4. Readings every 15 seconds: how do you convert np.diff(temps) to degrees per minute?

    Show answer

    Multiply by 4.

  5. What shape results from (3, 6) minus (6,)?

    Show answer

    (3, 6): the second array is broadcast across the rows.

  6. Why can NumPy work well with threads?

    Show answer

    It releases the GIL during most large operations.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz