Whole-array arithmetic: faster code, fewer loops, and formulas that look like the math on the page.
dtype, shape, and ndimCompanion notebook: lesson-05-numpy.ipynb — worked examples and self-checking exercises.
What a Python list really stores — and what an array stores instead.
list of floatsA list of pointers, each to a separate float object (24 bytes each) scattered around memory. Every + is looked up and dispatched at run time.
ndarrayOne contiguous block of raw 8-byte numbers (C-contiguous by default). Operations run in compiled C loops over the whole block.
import numpy as np
xs = list(range(1_000_000))
arr = np.arange(1_000_000, dtype=float)
sum(x * x for x in xs) # ≈ 38 ms
(arr * arr).sum() # ≈ 1 ms
arr.itemsize, arr.nbytes # (8, 8000000)
Roughly 30–50× faster here (timings vary by machine) — and the code is shorter. Vectorized code is usually both quicker and clearer.
And the difference between a view and a copy.
q = np.array([0.0010, 0.0012, 0.0015, 0.0019, 0.0024, 0.0031])
q.dtype, q.shape, q.ndim # (dtype('float64'), (6,), 1)
np.arange(1, 6) # [1 2 3 4 5]
np.linspace(0, 1, 5) # [0. 0.25 0.5 0.75 1. ]
np.zeros(3) # [0. 0. 0.]
np.ones((2, 3)) # 2 rows × 3 columns of 1.
np.full(4, 100.0) # [100. 100. 100. 100.]
m = np.arange(12).reshape(3, 4)
# [[ 0 1 2 3]
# [ 4 5 6 7]
# [ 8 9 10 11]]
dtype | element type — one per array (float64, int64, bool) |
shape | size along each axis — a tuple |
ndim | number of axes |
A shape (6,) array is a 1-D vector — not a row (1, 6) or a column (6, 1). That distinction matters for broadcasting.
np.arange excludes the stop, like range; np.linspace includes both ends.
m[1] # [4 5 6 7] a row
m[:, 2] # [ 2 6 10] a column
m[1, 2] # 6 one element: [row, col]
m[0:2, 1:3] # [[1 2]
# [5 6]]
# Boolean mask: keep where the condition is True
q[q > 0.0015] # [0.0019 0.0024 0.0031]
# Fancy indexing: pick by position list
q[[0, 2, 4]] # [0.001 0.0015 0.0024]
: alone means “everything along this axis”&, |, ~ — and parentheses:
q[(q > 0.001) & (q < 0.003)]Masks replace filter loops: claims[claims > deductible] - deductible in one line.
q = np.array([0.0010, 0.0012, 0.0015, 0.0019, 0.0024, 0.0031])
window = q[1:4] # a VIEW: same memory
window[0] = 99
q
# [1.0e-03 9.9e+01 1.5e-03 1.9e-03 2.4e-03 3.1e-03]
q = np.array([0.0010, 0.0012, 0.0015, 0.0019, 0.0024, 0.0031])
safe = q[1:4].copy() # a COPY: independent
safe[0] = 99
q # unchanged
np.shares_memory(q, q[1:4]) # True
np.shares_memory(q, q[[1, 2, 3]]) # False
| Operation | Returns |
|---|---|
Basic slice a[1:4] | view |
reshape, .T | view (usually) |
Mask a[a > 0] | copy |
Fancy a[[1, 2]] | copy |
Arithmetic a * 2 | new array |
The opposite of Python lists, where a slice is always a copy. Views are why NumPy slicing is cheap — and why stressing a “slice” of base assumptions can change the base.
How arrays of different shapes combine.
Compare shapes from the right. Two dimensions are compatible when they are equal, or one of them is 1. Size-1 dimensions are stretched to match.
| A | B | Result |
|---|---|---|
(6,) | scalar | (6,) |
(3, 1) | (5,) | (3, 5) |
(3, 1) | (4,) | (3, 4) |
(3,) | (4,) | error |
ValueError: operands could not be broadcast together
with shapes (3,) (4,)
A whole table of discount factors — three rates × five years — with no loop:
rates = np.array([0.03, 0.05, 0.07])[:, None] # (3, 1)
t = np.arange(1, 6) # (5,)
D = (1 + rates) ** -t # (3, 5)
D.round(4)
# [[0.9709 0.9426 0.9151 0.8885 0.8626]
# [0.9524 0.907 0.8638 0.8227 0.7835]
# [0.9346 0.8734 0.8163 0.7629 0.713 ]]
[:, None] adds an axis, turning a (3,) vector into a (3, 1) column.
x = np.array([0.02, 0.05, 0.10])
np.exp(x), np.log1p(x), np.sqrt(x) # element-wise "ufuncs"
np.maximum(claims - 500, 0) # vectorized max(loss - d, 0)
np.minimum(claims, 10_000) # cap at a limit
np.where(x > 0.04, "high", "low") # vectorized if/else
D.sum() # everything
D.sum(axis=0) # down the rows → one value per column
D.sum(axis=1) # across columns → one value per row
D.mean(), D.max(), D.cumsum(), D.cumprod()
np.maximum/np.minimum — not Python’s max/min — on arraysaxis=0 collapses rows; axis=1 collapses columnsD.sum(axis=1) on the discount table gives the 5-year annuity factor at each of the three rates. Lesson 1’s deductible-and-limit becomes np.minimum(np.maximum(loss - d, 0), u) for a million losses at once.
x = np.array([1.0, 2.0, 3.0])
y = np.array([4.0, 5.0, 6.0])
x @ y # 32.0 dot product x · y
A = np.array([[1, 2], [3, 4]])
B = np.array([[5, 6], [7, 8]])
A @ B # [[19 22] matrix product
# [43 50]]
A * B # [[ 5 12] element-wise — different!
# [21 32]]
A.T # [[1 3] transpose
# [2 4]]
Shapes must line up: (m, k) @ (k, n) → (m, n)
* multiplies element by element; @ is matrix multiplication. Mixing them up gives a result of the wrong shape — or, worse, the right shape and wrong numbers.
Price a whole portfolio of bonds with a single matrix product.
Build survival probabilities with cumprod, then value term insurance and an annuity with dot products.
Combine 10,000 scenarios of three asset returns into a portfolio distribution.
The rule for today: no Python for loops over cash flows, ages, or scenarios. If you write one, find the array operation that replaces it.
# rows = bonds, columns = years 1..5
CF = np.array([
[5, 5, 5, 5, 105], # 5y, 5% coupon
[3, 3, 103, 0, 0], # 3y, 3% coupon
[0, 0, 0, 0, 100], # 5y zero-coupon
[8, 8, 8, 8, 108], # 5y, 8% coupon
], dtype=float)
d = 1.04 ** -np.arange(1, 6) # flat 4% curve
prices = CF @ d
prices.round(4)
# [104.4518 97.2249 82.1927 117.8073]
CouponBond(100, 0.05, 5, freq=1).price(0.04)(3, 5) discount matrix by broadcasting, then CF @ D.T → shape (4, 3)(CF * d * t).sum(axis=1) / pricesReal portfolios have thousands of bonds and hundreds of periods. The code doesn’t change.
The SOA’s Standard Ultimate Survival Model (Makeham): A = 0.00022, B = 2.7×10−6, c = 1.124.
$$p_x = \exp\!\Big(-A - \frac{B c^x (c-1)}{\ln c}\Big)$$
A, B, c = 0.00022, 2.7e-6, 1.124
ages = np.arange(60, 70)
px = np.exp(-A - B * c**ages * (c - 1) / np.log(c))
qx = 1 - px
# q_60 = 0.003398 ... q_69 = 0.009294
kpx = np.concatenate([[1.0], np.cumprod(px)[:-1]]) # _k p_60
v = 1.05 ** -np.arange(1, 11)
A_term = v @ (kpx * qx) # 10-year term, age 60
a_due = (1.05 ** -np.arange(10)) @ kpx
| Quantity (age 60, 10 years, 5%) | Value |
|---|---|
| $A^{1}_{60:\overline{10}|}$ | 0.042521 |
| $\ddot{a}_{60:\overline{10}|}$ | 7.955548 |
| EPV of 100,000 death benefit | 4,252.09 |
| Level annual net premium | 534.48 |
cumprod and deaths dx with np.diff — 5,745.08 deaths over 10 yearsrng = np.random.default_rng(2026) # next lesson's topic!
mu = np.array([0.06, 0.03, 0.08]) # equities, bonds, property
sd = np.array([0.15, 0.05, 0.20])
corr = np.array([[1.0, 0.2, 0.7],
[0.2, 1.0, 0.1],
[0.7, 0.1, 1.0]])
cov = np.outer(sd, sd) * corr
R = rng.multivariate_normal(mu, cov, size=10_000) # (10000, 3)
w = np.array([0.5, 0.3, 0.2]) # weights
port = R @ w # (10000,)
| Simulated | Theory | |
|---|---|---|
| Mean return | 0.0564 | w · μ = 0.0550 |
| Std. deviation | 0.1108 | √(wᵀΣw) = 0.1105 |
| 5th percentile | −0.1253 | — |
@ and np.sqrtR @ W.T with W of shape (1000, 3), rows summing to 1shape before I combine arrays.copy() when neededaxis= deliberately in aggregates@ from *Reproducible random numbers, claim-frequency models, and simulating 10,000 lives to price stop-loss cover.