Exercises — NumPy arrays and vectorized operations
Before we start, three tiny bits of vocabulary that the exercises reuse. Read these once; each is one sentence.
Now, one picture fixes the single rule that half of these problems test.
The One Rule: Broadcasting, drawn

How to read the figure (it drives Exercises 3.1 and 4.1). The two shapes (4,1,3) and (5,3) are stacked with their right edges flush — this is step 1 of the rule made visual. (5,3) is shorter, so a 1 is prepended (step 2) to make (1,5,3). Now walk the three columns:
- Rightmost column
3vs3: equal, no stretch needed → keep3. - Middle column
1vs5: the gray arrow shows array A's length-1axis being virtually repeated 5 times (step 4) → becomes5. - Leftmost column
4vs1: array B's length-1axis is repeated 4 times → becomes4.
The green row at the bottom is the output shape (4,5,3), and each green cell is literally the max of the two cells above it. The red note reminds you of the only failure mode: a column with two different numbers where neither is 1 — no legal repetition exists. Everything below refers back to this exact walk.
Keep this picture in your head; problems L1–L5 all lean on it.
L1 — Recognition
Exercise 1.1 (L1)
State the shape and dtype NumPy infers for:
a = np.array([[1, 2, 3], [4, 5, 6]])Recall Solution
Two rows, three columns, so ==shape = (2, 3).
Every entry is a whole number, so NumPy picks an integer type: dtype = int64== (on 64-bit platforms).
Why: shape is read outer-list-length first (2 sub-lists), then inner-list-length (3 numbers each). dtype is the smallest common type that holds every element without loss — all ints ⇒ int64.
Exercise 1.2 (L1)
What does each line return?
np.arange(0, 10, 2) # (a)
np.linspace(0, 1, 5) # (b)
np.zeros((2, 3)).dtype # (c)Recall Solution
- (a)
array([0, 2, 4, 6, 8])— start at 0, step 2, stop before 10. - (b)
array([0. , 0.25, 0.5 , 0.75, 1. ])— 5 points, endpoints included, gaps of . - (c)
float64—zeros/onesdefault to floating point. Why (b) has step 0.25 and not 0.2:linspace(a, b, N)placesNpoints, so there areN-1gaps: .
Exercise 1.3 (L1)
For A of shape (3, 4), what shape does the slice A[1:3, :] have, and is it a view or a copy?
Recall Solution
Rows 1 and 2 (Python stops before 3), all columns ⇒ shape ==(2, 4).
It is a view== — it shares the parent's memory buffer. Writing into it writes into A.
Why a view: basic slicing only changes the start offset and strides, never copies data. This is why slicing multi-GB arrays is instant.
L2 — Application
Exercise 2.1 (L2)
Given
a = np.array([1, 2, 3]) # shape (3,)
b = np.array([[10], [20]]) # shape (2, 1)
c = a + bWrite out c fully and give its shape.
Recall Solution
First write each shape explicitly. a is a 1-D array of length 3, so its shape is (3,). b is 2-D, shape (2, 1).
Align from the right and prepend a 1 to the shorter shape so both have the same length:
Now stack them, right edges flush, and read each column:
- Column 0:
2vs1→ the1(froma) stretches up to 2 rows. - Column 1:
1vs3→ the1(fromb) stretches across to 3 columns. So(2, 1)vs(1, 3)→ output shape ==(2, 3)==. Row 0 usesb=10:[11, 12, 13]. Row 1 usesb=20:[21, 22, 23]. Why:a(a single row) is repeated down to fill 2 rows;b(a single column) is repeated across to fill 3 columns. Every cell isa[j] + b[i]— an outer sum.
Exercise 2.2 (L2)
Replace this loop with one vectorized line, and give the result for data = np.array([-3, -1, 0, 2, 5]):
out = np.empty_like(data)
for i in range(len(data)):
out[i] = data[i] if data[i] > 0 else 0Recall Solution
This is the ReLU function. Vectorized:
out = np.maximum(data, 0)Result: ==array([0, 0, 0, 2, 5])==.
Why np.maximum and not max: np.maximum is a ufunc (defined at the top of this page — a compiled, element-wise function). It broadcasts the scalar 0 against every element and runs the whole comparison in C. Plain max(data, 0) tries to compare the whole array object to 0 and raises an error, and a Python loop would be ~100× slower for the same result.
Exercise 2.3 (L2)
Given x = np.array([10, 20, 30, 40, 50]), use boolean indexing to return only the elements strictly greater than 25, then their sum.
Recall Solution
mask = x > 25 # array([False, False, True, True, True])
x[mask] # array([30, 40, 50])
x[mask].sum() # 120Selected elements: ==[30, 40, 50], sum 120==.
Why: x > 25 builds a boolean array of the same shape; x[mask] keeps positions where the mask is True. This creates a copy (the survivors are not contiguous in memory).
L3 — Analysis
Exercise 3.1 (L3)
Do these broadcast? If yes, give the output shape; if no, say which aligned column breaks the rule.
(a) (3, 4) with (4,)
(b) (3, 4) with (5,)
(c) (4, 1, 3) with (5, 3)
(d) (2, 3) with (2, 1)
(e) (0, 3) with (3,) # zero-length first dimension
Recall Solution
- (a) align:
(3,4)vs(1,4)→ columns(3,1)ok,(4,4)ok ⇒(3, 4). - (b) align:
(3,4)vs(1,5)→ last column(4,5): neither equals nor is 1 ⇒ FAILS (the4vs5column). - (c) align:
(4,1,3)vs(1,5,3)→ columns(4,1)→4,(1,5)→5,(3,3)→3 ⇒(4, 5, 3)(this is exactly the figure at the top). - (d) columns
(2,2)ok,(3,1)ok ⇒(2, 3). - (e) align:
(0,3)vs(1,3)→ column 0 is0vs1: one side is 1, so it is compatible and stretches tomax(0,1)=0; column 1 is3vs3ok ⇒(0, 3). The result is a valid empty array — no error, just zero rows. Why (b) breaks: two "real" sizes 4 and 5 can only match if identical; there is no size-1 dimension to stretch, so no legal repetition exists (the red column in the figure). Why (e) does NOT break: the rule is equal, or one is 1. Here0vs1satisfies "one is 1", and the output takesmax(0,1)=0. A0only conflicts with a size ≥ 2 — e.g.(0,3)vs(2,3)would fail on the first column (0vs2, neither is 1).
Exercise 3.2 (L3)
v = np.array([1., 2., 3., 4.]). You want to normalize it to unit length. Explain why
v / v.sum()is wrong and write the correct expression. Give the numeric result of the correct one.
Recall Solution
v.sum() = 10, so v / v.sum() = [0.1, 0.2, 0.3, 0.4] — those sum to 1, but that is probability normalization, not length normalization.
"Unit length" means the Euclidean norm equals 1:
Correct:
v / np.linalg.norm(v)Result: [0.1826, 0.3651, 0.5477, 0.7303] (each is ). Check: their squares sum to 1.
Why the tool: np.linalg.norm computes , the geometric length; dividing by it rescales the vector to length 1 while keeping its direction.
Exercise 3.3 (L3)
A has shape (1000, 3) (1000 points in 3-D). You want to subtract the column mean from each column (center the data). Why does A - A.mean(axis=0) work but A - A.mean(axis=1) fail, and what shapes appear?
Recall Solution
A.mean(axis=0)collapses the rows (axis 0), leaving one mean per column: shape(3,). Broadcasting(1000,3)vs(3,)→ align(1000,3)vs(1,3)⇒ works, output(1000, 3).A.mean(axis=1)collapses the columns, giving one mean per row: shape(1000,). Broadcasting(1000,3)vs(1000,)→ align(1000,3)vs(1,1000)→ last column(3, 1000)⇒ FAILS. Fix for row-centering (if you truly wanted it): keep the axis withkeepdims=Trueso the shape is(1000, 1), which does broadcast against(1000, 3). Whyaxis=0: "axis 0" is the axis you move along and destroy. Averaging along axis 0 walks down the 1000 rows and returns a value for each of the 3 columns — exactly the per-feature mean ML wants.
L4 — Synthesis
Exercise 4.1 (L4)
Compute the pairwise squared Euclidean distance between every pair of the points
P = np.array([[0., 0.],
[3., 0.],
[0., 4.]]) # shape (3, 2): three 2-D pointsusing only broadcasting (no Python loop). Give the full 3×3 result matrix D where D[i,j] = ||P[i] - P[j]||².
Recall Solution
The trick: make the row-index and column-index live on different axes, then let broadcasting form all pairs. We use the None-new-axis tool defined at the top of the page.
diff = P[:, None, :] - P[None, :, :] # shape (3,1,2) - (1,3,2) -> (3,3,2)
D = (diff ** 2).sum(axis=2) # sum over the coordinate axis -> (3,3)Why the Nones. P has shape (3,2). Writing P[:, None, :] inserts a length-1 axis in the middle, giving (3,1,2) — "points down axis 0". Writing P[None, :, :] inserts a length-1 axis at the front, giving (1,3,2) — "points across axis 1". Broadcasting stretches both length-1 axes to size 3, producing (3,3,2), so diff[i,j] is exactly the vector . Squaring and summing the last axis (the two coordinates) gives the squared distance.
Distances: –: ; –: ; –: .
Why this beats a double loop: the loop is in Python; the broadcast version is in C — same math, ~100× faster, and it is the exact pattern behind k-nearest-neighbours and attention. See 2.1.03-Matrix-operations-for-ML.

How to read the figure (it is the geometry behind the matrix D). The three coloured dots are the rows of P plotted on the plane. Each gray line is one difference vector that the broadcast diff array holds; the red label on each line is its squared length, which is precisely the matrix entry D[i,j]. Trace them back to the derivation: the line from to has horizontal drop 3 and vertical rise 4, so its squared length is — the 25 in D. The diagonal of D is zero (the arrow from a point to itself has length 0), and D is symmetric because the arrow has the same length as .
Exercise 4.2 (L4)
Implement a numerically-safe softmax of a row vector z in vectorized NumPy, and evaluate it for z = np.array([1., 2., 3.]). The softmax of entry is
Recall Solution
z_shift = z - z.max() # subtract max for numerical stability
e = np.exp(z_shift) # np.exp is a ufunc: element-wise
soft = e / e.sum()With z.max() = 3: shifted [-2, -1, 0], exps [0.1353, 0.3679, 1.0], sum 1.5032.
Result: ==[0.0900, 0.2447, 0.6652]== (sums to 1).
Why subtract the max: exp overflows for large inputs. Subtracting a constant from every multiplies numerator and denominator by , which cancels — the softmax is unchanged, but now the largest exponent is , so no overflow. This is a required trick in every deep-learning framework; see 3.1.02-Tensor-operations-in-PyTorch.
L5 — Mastery
Exercise 5.1 (L5)
You must solve with
A = np.array([[3., 1.], [1., 2.]])
b = np.array([9., 8.])(1) Solve it by hand and confirm. (2) Explain, in cost and stability terms, why np.linalg.solve(A, b) is preferred over np.linalg.inv(A) @ b.
Recall Solution
(1) By hand. Write the two equations the matrix encodes:
- Row 1:
- Row 2:
From row 2, isolate : . Substitute into row 1: Back-substitute: . So In NumPy:
np.linalg.solve(A, b) # array([2., 3.])(2) Cost. For an system, np.linalg.solve does one LU factorization (, with lower-triangular and upper-triangular) costing about floating-point operations, then a forward substitution (solve ) and a back substitution (solve ), each only . Total .
By contrast, np.linalg.inv(A) computes the full inverse, which is like solving separate systems (one for each column of the identity), costing about operations — roughly 3× more work — and then you still pay an extra for the matrix–vector product inv(A) @ b.
Stability. Forming explicitly and multiplying by it accumulates extra rounding error; the accuracy of any linear solve already degrades with the condition number of (how much it amplifies input error), and routing through the explicit inverse adds roundoff that the direct substitution in solve avoids. For ill-conditioned , inv(A) @ b is visibly less accurate than solve(A, b).
Rule of thumb: never invert to solve — factor and substitute. Use np.linalg.solve. This same principle underlies the linear-algebra kernels in 2.1.03-Matrix-operations-for-ML.
Exercise 5.2 (L5)
Rank these three implementations of y = x² for x = np.arange(1_000_000, dtype=float) from fastest to slowest, and justify with the mechanism:
# (A) [xi**2 for xi in x]
# (B) x ** 2
# (C) np.array([xi**2 for xi in x])Recall Solution
Fastest → slowest: ==(B) < (A) < (C)== (B is fastest).
- (B)
x ** 2is a single ufunc call: the whole million-element loop runs in compiled C with SIMD, no Python-level per-element overhead. ~milliseconds. - (A) the list comprehension runs a Python-level loop (interpreter dispatch, boxing each float) — ~100× slower than (B). It also returns a list, not an array.
- (C) does everything (A) does and then copies the million-element list into a fresh ndarray — strictly more work than (A), so it is slowest. Why (B) wins the mechanism argument: the cost of a Python loop is dominated by per-iteration interpreter overhead (type checks, refcounts), not by the multiply itself. Vectorization pays that overhead once. This is the whole reason NumPy exists — see 1.4.02-List-comprehensions-and-generators for the loop it replaces.
Exercise 5.3 (L5)
a = np.arange(5). Predict the value of a after each line, and explain the memory difference between lines 1 and 2.
b = a # line 1
b[0] = 99 # line 2
c = a.copy() # line 3
c[1] = -1 # line 4Recall Solution
- After line 2:
a = [99, 1, 2, 3, 4].b = abinds a second name to the same array object — no copy, so mutatingbmutatesa. - After line 4:
ais unchanged at[99, 1, 2, 3, 4];c = [99, -1, 2, 3, 4].a.copy()allocates a new buffer, so writing toccannot toucha. Why it matters: slices and plain assignment are views/aliases (cheap, shared memory);.copy()is an explicit new buffer. Silent aliasing bugs — mutating a "view" you thought was independent — are the #1 source of NumPy correctness errors.
Quick self-test
Cover the right side.
Broadcasting compatibility of (6, 1) and (1, 4)
(6, 4)Broadcasting compatibility of (3,) and (4,)
Broadcasting compatibility of (0, 3) and (3,)
(0, 3), a valid empty array (0 vs 1 → one is 1)What does v[:, None] do to shape (3,)
(3, 1) — inserts a length-1 axis (that is np.newaxis)np.maximum(x, 0) implements which activation
A ufunc is
np.exp, +, **)Divide a vector by which quantity to get unit length
np.linalg.norm(v)axis=0 on a (1000, 3) array averages over which entities
Why subtract z.max() inside softmax
exp overflow without changing the resultWhy solve(A,b) over inv(A)@b
b = a; b[0] = 99 — is a changed
b is an alias, not a copy