NumPy
Lesson 4 — Python
Learning objectives
- Create and inspect NumPy arrays
- Use vectorised operations instead of loops
- Apply broadcasting rules deliberately
- Index and slice arrays, including boolean masks
- Handle missing values with
nan - Understand where views differ from copies
Why NumPy
Python lists are flexible and slow. NumPy arrays are homogeneous, contiguous in memory, and operations on them run in compiled C.
import numpy as np
# Pure Python
values = list(range(1_000_000))
squares = [x**2 for x in values] # ~60 ms
# NumPy
arr = np.arange(1_000_000)
squares = arr**2 # ~1 msNumPy is also the foundation of pandas, scikit-learn, SciPy and every other scientific Python library. Understanding arrays is prerequisite to understanding pandas.
This is exactly R’s vectorisation argument. An R vector is essentially a NumPy array — contiguous, homogeneous, operated on element-wise in compiled code.
Creating arrays
np.array([1, 2, 3]) # from a list
np.array([[1, 2], [3, 4]]) # 2-D
np.zeros(5) # [0. 0. 0. 0. 0.]
np.ones((2, 3)) # 2x3 of ones
np.full(5, 7) # [7 7 7 7 7]
np.empty(5) # uninitialised — contains garbage
np.arange(0, 10, 2) # [0 2 4 6 8]
np.linspace(0, 1, 5) # [0. 0.25 0.5 0.75 1.]
np.eye(3) # identity matrix
np.random.default_rng(42).normal(0, 1, 100) # modern RNG APIInspect:
a = np.array([[1, 2, 3], [4, 5, 6]])
a.shape # (2, 3)
a.ndim # 2
a.size # 6
a.dtype # dtype('int64')
a.nbytes # 48
a.T # transposedtypes
np.array([1, 2, 3]).dtype # int64
np.array([1.0, 2, 3]).dtype # float64
np.array([1, 2, 3], dtype=np.float32)
np.array(["a", "b"]).dtype # <U1 (unicode, length 1)
np.array([True, False]).dtype # bool
a.astype(np.float64)A NumPy array is homogeneous — all elements share one dtype. Mixing types promotes, exactly as R’s coercion rules do:
np.array([1, 2.5]) # array([1. , 2.5]) -> float64
np.array([1, "a"]) # array(['1', 'a']) -> <U21np.array([2**62], dtype=np.int64) * 4
# array([0]) — wrapped around, no warning
np.array([2**62], dtype=np.float64) * 4
# array([1.8446744e+19])R’s integers are 32-bit and produce NA with a warning on overflow. NumPy’s default is 64-bit and wraps silently. For counts and IDs this rarely matters; for accumulating large sums, use float64 or np.int64 with awareness.
Vectorised operations
a = np.array([1, 2, 3, 4])
b = np.array([10, 20, 30, 40])
a + b # [11 22 33 44]
a * b # [10 40 90 160]
a ** 2 # [1 4 9 16]
np.sqrt(a)
np.log(a)
np.exp(a)
np.abs(-a)
np.round(a / 3, 2)
a > 2 # [False False True True]
(a > 2) & (b < 35) # element-wise AND — note the parentheses
~(a > 2) # NOT&, |, ~ — not and, or, not
(a > 2) and (b < 35)
# ValueError: The truth value of an array with more than one element is ambiguous
(a > 2) & (b < 35) # correctand/or try to evaluate the whole array as a single boolean. NumPy and pandas require the bitwise operators, and the parentheses are mandatory because & binds more tightly than >.
This is identical to R’s & versus && distinction.
Aggregations:
a.sum(); a.mean(); a.std(); a.min(); a.max()
a.cumsum(); a.cumprod()
np.median(a); np.percentile(a, [25, 50, 75])
np.corrcoef(a, b)
m = np.array([[1, 2, 3], [4, 5, 6]])
m.sum() # 21 everything
m.sum(axis=0) # [5 7 9] down the columns
m.sum(axis=1) # [6 15] across the rowsaxis=0 collapses rows (giving one value per column); axis=1 collapses columns. Remembering which is which: the axis you name is the one that disappears.
Broadcasting
Operations between arrays of different shapes, where NumPy stretches the smaller one.
a = np.array([1, 2, 3])
a * 2 # [2 4 6] scalar broadcast
m = np.array([[1, 2, 3],
[4, 5, 6]])
m + np.array([10, 20, 30]) # adds the row vector to EVERY row
#> [[11 22 33]
#> [14 25 36]]
m + np.array([[10], [20]]) # adds the column vector to every column
#> [[11 12 13]
#> [24 25 26]]The rules, applied from the trailing dimension backwards:
- Dimensions are compatible if they are equal, or one of them is 1
- Missing dimensions are treated as 1
- Otherwise it is an error
(3, 4) + (4,) -> (3, 4) OK
(3, 4) + (3, 1) -> (3, 4) OK
(3, 4) + (3,) -> ERROR 4 vs 3
Centring a matrix by column:
data = np.random.default_rng(0).normal(50, 10, (100, 5))
centred = data - data.mean(axis=0) # (100,5) - (5,) -> broadcast
standardised = centred / data.std(axis=0)Like R’s recycling, broadcasting is powerful and occasionally produces a plausible result from a mistake. If two arrays happen to have compatible shapes for the wrong reason, you get numbers rather than an error.
Assert shapes when it matters:
assert data.shape[1] == len(weights), (
f"weights has {len(weights)} elements, data has {data.shape[1]} columns"
)Indexing
a = np.array([10, 20, 30, 40, 50])
a[0] # 10
a[-1] # 50
a[1:4] # [20 30 40]
a[::2] # [10 30 50]
a[::-1] # reversed
m = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
m[0, 1] # 2 row 0, column 1
m[0] # [1 2 3] row 0
m[:, 1] # [2 5 8] column 1
m[0:2, 1:3] # [[2 3], [5 6]]
m[[0, 2], :] # rows 0 and 2 — fancy indexingBoolean masks
The workhorse of data selection:
ages = np.array([45, 72, 38, 66, 29])
mask = ages >= 65
mask # [False True False True False]
ages[mask] # [72 66]
ages[ages >= 65] # same, inline
ages[(ages >= 40) & (ages < 70)] # [45 66]
np.where(ages >= 65, "elderly", "adult")
#> array(['adult', 'elderly', 'adult', 'elderly', 'adult'], dtype='<U7')
np.where(ages >= 65) # indices where True
#> (array([1, 3]),)
np.select(
[ages < 18, ages < 65],
["child", "adult"],
default="elderly",
)np.select is the NumPy equivalent of dplyr::case_when().
Modification through a mask:
ages[ages > 100] = np.nan # ValueError on an int array — nan is a float
ages = ages.astype(float)
ages[ages > 100] = np.nan # now fineMissing values
NumPy has no NA. Missingness is np.nan, which is a float.
a = np.array([1, 2, np.nan, 4])
a.dtype # float64 — the nan forced it
a.sum() # nan — propagates
np.nansum(a) # 7.0
np.nanmean(a) # 2.333
np.nanstd(a)
np.nanmax(a)
np.isnan(a) # [False False True False]
a[~np.isnan(a)] # [1. 2. 4.]
np.nan == np.nan # False! — always use np.isnan()nan is not equal to itself
np.nan == np.nan # False
np.nan is np.nan # True (same object, but do not rely on this)
np.isnan(np.nan) # True — the correct testThis is IEEE 754 behaviour, shared by R’s NaN. The difference is that R has a separate NA for statistical missingness, typed per column; NumPy has only the float nan.
The practical consequence: an integer NumPy array cannot hold a missing value. pandas addressed this with nullable dtypes — see lesson 5.
Views versus copies
Slicing returns a view — a window onto the same memory.
a = np.array([1, 2, 3, 4, 5])
b = a[1:4] # a VIEW
b[0] = 99
a # [1 99 3 4 5] — a changed!
c = a[1:4].copy() # an explicit copy
c[0] = 0
a # unchanged
b.base is a # True — b is a view of a
c.base is None # True — c owns its dataFancy indexing and boolean masking return copies:
d = a[[1, 2, 3]] # fancy indexing -> copy
e = a[a > 2] # boolean mask -> copyViews are a performance feature — slicing a 10 GB array costs nothing. They are also a source of surprising mutation, in exactly the way R’s copy-on-modify semantics protect against. When in doubt, .copy().
Random numbers
rng = np.random.default_rng(42) # the modern API
rng.normal(50, 10, 100)
rng.uniform(0, 1, 10)
rng.integers(1, 7, 10) # dice
rng.choice(["A", "B", "C"], 20)
rng.choice(subjects, 50, replace=False) # sample without replacement
rng.permutation(arr)
rng.shuffle(arr) # in placeAlways seed for reproducibility. Use default_rng rather than the legacy np.random.seed() / np.random.normal() functions — the generator object is explicit, and independent generators do not interfere.
Linear algebra
A = np.array([[1, 2], [3, 4]])
b = np.array([5, 6])
A @ A # matrix multiplication
A * A # ELEMENT-WISE — not matrix multiplication
np.linalg.inv(A)
np.linalg.solve(A, b) # solve Ax = b — better than inv(A) @ b
np.linalg.det(A)
np.linalg.eig(A)
np.linalg.cholesky(A)@ versus * is the equivalent of R’s %*% versus *, and the mistake goes the same way.
R and Python side by side
| Task | R | NumPy |
|---|---|---|
| Create | c(1, 2, 3) |
np.array([1, 2, 3]) |
| Sequence | 1:10 |
np.arange(1, 11) |
| Repeat | rep(0, 5) |
np.zeros(5) |
| Even spacing | seq(0, 1, length.out = 5) |
np.linspace(0, 1, 5) |
| Length | length(x) |
x.size |
| Dimensions | dim(x) |
x.shape |
| Type | typeof(x) |
x.dtype |
| Index | x[1] (1-based) |
x[0] (0-based) |
| Slice | x[2:4] (inclusive) |
x[1:4] (exclusive stop) |
| Boolean filter | x[x > 5] |
x[x > 5] |
| Conditional | ifelse(c, a, b) |
np.where(c, a, b) |
| Multi-branch | case_when() |
np.select() |
| Missing | NA |
np.nan (float only) |
| Ignore missing | mean(x, na.rm = TRUE) |
np.nanmean(x) |
| Test missing | is.na(x) |
np.isnan(x) |
| Matrix multiply | A %*% B |
A @ B |
| Column sums | colSums(m) |
m.sum(axis=0) |
| Assignment | Copies | View for slices |
Common mistakes
| Mistake | Consequence | Fix |
|---|---|---|
and/or on arrays |
ValueError |
&, |, with parentheses |
| Missing parentheses around comparisons | Wrong precedence | (a > 2) & (b < 5) |
x == np.nan |
Always False |
np.isnan(x) |
| Expecting a slice to copy | Original mutated | .copy() |
* for matrix multiplication |
Element-wise result | @ |
Wrong axis |
Aggregated the wrong way | The named axis disappears |
Integer array assigned nan |
ValueError |
Cast to float first |
| Looping over an array | Slow | Vectorise |
Exercise 4.1 — Vectorise a loop
Rewrite this without a loop, and measure the difference.
def normalise(values, lower, upper):
result = []
for v in values:
if v is None:
result.append(None)
elif v < lower:
result.append(0.0)
elif v > upper:
result.append(1.0)
else:
result.append((v - lower) / (upper - lower))
return resultShow solution
import numpy as np
def normalise(values, lower, upper):
"""Scale values to [0, 1], clipping outside the range.
Args:
values: Array-like of numbers; NaN propagates.
lower: Value mapped to 0.0.
upper: Value mapped to 1.0. Must be greater than `lower`.
"""
if upper <= lower:
raise ValueError(f"upper ({upper}) must exceed lower ({lower})")
arr = np.asarray(values, dtype=float) # None becomes nan
scaled = (arr - lower) / (upper - lower)
return np.clip(scaled, 0.0, 1.0) # nan survives clipvalues = [None, 5.0, 15.0, 25.0, 35.0]
normalise(values, 10, 30)
#> array([ nan, 0. , 0.25, 0.75, 1. ])The three pieces:
np.asarray(values, dtype=float)converts a list to an array and turnsNoneintonanin one step.asarrayrather thanarrayavoids a copy when the input is already an array.- The arithmetic is vectorised — one expression for the whole array.
np.clipreplaces the two conditional branches, and propagatesnanrather than clipping it.
Timing:
import timeit
data = list(np.random.default_rng(0).normal(20, 10, 1_000_000))
# Loop version
timeit.timeit(lambda: normalise_loop(data, 10, 30), number=1)
#> 0.42 s
# Vectorised
timeit.timeit(lambda: normalise(data, 10, 30), number=1)
#> 0.006 s
# Already an array — no conversion cost
arr = np.asarray(data)
timeit.timeit(lambda: normalise(arr, 10, 30), number=1)
#> 0.003 sRoughly 70× faster on a list, 140× on an array. Most of the remaining cost in the list case is the list-to-array conversion, which is why keeping data in arrays (or a DataFrame) throughout a pipeline matters more than optimising any single operation.
A subtlety worth checking. Does np.clip handle nan correctly?
np.clip(np.array([np.nan, 0.5, 2.0]), 0, 1)
#> array([nan, 0.5, 1. ])nan passes through. That is the behaviour we want, and it is worth verifying rather than assuming, because np.minimum/np.maximum and np.fmin/np.fmax differ from each other precisely on nan handling.
Exercise 4.2 — Reference range flags
Given arrays of lab values, lower limits and upper limits, compute a normal-range indicator ("LOW", "NORMAL", "HIGH", "MISSING") and the count in each category — without any loops. Handle missing limits.
Show solution
import numpy as np
def anrind(values, lower, upper):
"""Derive a normal range indicator.
Args:
values: Lab results. NaN gives "MISSING".
lower: Lower limits of normal. NaN means no lower limit.
upper: Upper limits of normal. NaN means no upper limit.
Returns:
An array of "LOW", "NORMAL", "HIGH" or "MISSING".
"""
values = np.asarray(values, dtype=float)
lower = np.asarray(lower, dtype=float)
upper = np.asarray(upper, dtype=float)
if not (values.shape == lower.shape == upper.shape):
raise ValueError(
f"Shape mismatch: values {values.shape}, "
f"lower {lower.shape}, upper {upper.shape}"
)
# Comparisons with nan are False, which is what we want: a missing limit
# means the value cannot be low/high on that side.
with np.errstate(invalid="ignore"):
is_low = values < lower
is_high = values > upper
return np.select(
[np.isnan(values), is_low, is_high],
["MISSING", "LOW", "HIGH"],
default="NORMAL",
)values = np.array([15.0, 45.0, 80.0, np.nan, 30.0])
lower = np.array([20.0, 20.0, 20.0, 20.0, np.nan])
upper = np.array([60.0, 60.0, 60.0, 60.0, 60.0])
result = anrind(values, lower, upper)
result
#> array(['LOW', 'NORMAL', 'HIGH', 'MISSING', 'NORMAL'], dtype='<U7')
labels, counts = np.unique(result, return_counts=True)
dict(zip(labels, counts))
#> {'HIGH': 1, 'LOW': 1, 'MISSING': 1, 'NORMAL': 2}Three details that make this correct rather than nearly correct:
1. np.isnan(values) must be the first condition in np.select. np.select takes the first matching condition. A nan value compared with a limit gives False for both is_low and is_high, so without the explicit missing check it would fall through to the default and be labelled "NORMAL" — which is badly wrong for a lab dataset.
2. Missing limits are handled by the comparison semantics. The last row has no lower limit (nan), so 30.0 < nan is False and the value cannot be flagged "LOW". It is correctly "NORMAL" because it is below the upper limit. That falls out of IEEE 754 rather than needing special handling — but it is worth stating explicitly in a comment, because a reader will otherwise wonder whether it was considered.
3. np.errstate(invalid="ignore") suppresses the RuntimeWarning NumPy emits when comparing with nan. Without it, a 200,000-row lab dataset produces a wall of warnings. Scoping the suppression to just these two lines is important — a blanket warnings.filterwarnings("ignore") would hide genuine problems elsewhere.
Verification
# A value with NO limits at all must be NORMAL, not MISSING
anrind([30.0], [np.nan], [np.nan])
#> array(['NORMAL'], dtype='<U7')
# A value exactly on a limit is NORMAL (limits are inclusive)
anrind([20.0, 60.0], [20.0, 20.0], [60.0, 60.0])
#> array(['NORMAL', 'NORMAL'], dtype='<U7')ANRLO <= AVAL <= ANRHI as normal, so strict < and > for the abnormal flags is correct. It is exactly the kind of boundary decision that double programming exists to catch, and it belongs in a test.
Recap
- Arrays are homogeneous and contiguous; operations run in compiled C
&,|,~with parentheses — neverand/oron arrays- Broadcasting stretches smaller arrays; assert shapes when correctness depends on it
axis=0collapses rows,axis=1collapses columns — the named axis disappearsnanis a float and is never equal to itself; usenp.isnan()and thenan*functions- Slices are views; fancy indexing and masks are copies
np.wherefor two branches,np.selectfor manynp.random.default_rng(seed), not the legacy API
Next: pandas.