Vectors and matrices: the language of data
The scene: why do we need maths
Up to Chapter 11 you built models, evaluated them and analysed their mistakes. You didn't need maths.
So why now?
Because if you don't know what happens inside fit(), there are three things you can't do:
- Explain why the model predicted exactly that
- Find out why the model fell over when it fails
- Connect a new algorithm to the ones you already know
This part holds the three most "mathematical" chapters of the book. But don't be afraid: we need only three operations, and numpy does all of them.
If maths scares you
You can skip this chapter and go to Chapter 15; the book still works. Come back later.
But know one thing: the dot product below is the most important operation in all of ML. It works exactly the same in logistic regression, in a neural network and in a Transformer.
Column names
The data is synthetic Tashkent rental listings, and its column names are Uzbek. xonalar rooms, maydon_m2 area in m², metro_km distance to the metro, mebel furnished (0/1), narx_usd monthly rent in USD.
One row is a vector
import pandas as pd import numpy as np df = pd.read_csv(DATA / "uy_ijara.csv") num = ["xonalar", "maydon_m2", "metro_km", "mebel"] one = df.loc[0, num] print("One flat (one row):") print(one.to_string()) print() x = one.to_numpy(dtype=float) print("numpy vector:", x) print("shape:", x.shape, " -> 4 numbers")
One flat (one row): xonalar 2 maydon_m2 40.6 metro_km 1.43 mebel 1 numpy vector: [ 2. 40.6 1.43 1. ] shape: (4,) -> 4 numbers
In maths you write it like this:
The bold letter x is a vector (a list of numbers). A plain
The whole table is a matrix
X = df[num].to_numpy(dtype=float) print("The whole table:") print(f" shape: {X.shape} -> {X.shape[0]} rows (samples), {X.shape[1]} columns (features)") print() print("First 3 rows:") print(X[:3]) print() print("Taking elements from the matrix:") print(f" X[0, 1] = {X[0, 1]} -> row 1, column 2 (the first flat's area)") print(f" X[0] = {X[0]} -> the whole of row 1 (one flat)") print(f" X[:, 1][:5] = {X[:, 1][:5]} -> the first 5 values of column 2 (areas)")
The whole table: shape: (1200, 4) -> 1200 rows (samples), 4 columns (features) First 3 rows: [[ 2. 40.6 1.43 1. ] [ 2. 55.5 4.18 1. ] [ 5. 114. 3.84 1. ]] Taking elements from the matrix: X[0, 1] = 40.6 -> row 1, column 2 (the first flat's area) X[0] = [ 2. 40.6 1.43 1. ] -> the whole of row 1 (one flat) X[:, 1][:5] = [ 40.6 55.5 114. 72.1 46.4] -> the first 5 values of column 2 (areas)
The rule: a row is a sample, a column is a feature. sklearn always expects this shape: (n_samples, n_features).
Always check shape
Half the mistakes in ML come from mismatched shapes:
ValueError: Found input variables with inconsistent numbers of samples: [840, 360]
When you see this error, the first thing to do is print(X.shape, y.shape). The second is to work out which one is wrong.
The most important operation: the dot product
Now we reach the heart of all of ML.
The question: what do we do to predict a flat's price?
We give every feature a weight and add them up:
# weights: how much each feature affects the price w = np.array([55.0, 7.5, -28.0, 90.0]) b = 180.0 # bias (the starting value) print("The weights and what they mean:") for name, weight in zip(num, w): print(f" {name:12s} {weight:>7.1f}") print(f" {'bias':12s} {b:>7.1f}") print() print("A prediction for one flat, BY HAND:") steps = [f"{x[i]:.2f} x {w[i]:.1f}" for i in range(len(w))] print(" " + " + ".join(steps) + f" + {b:.0f}") pred_by_hand = sum(x[i] * w[i] for i in range(len(w))) + b print(f" = {pred_by_hand:.2f}")
The weights and what they mean: xonalar 55.0 maydon_m2 7.5 metro_km -28.0 mebel 90.0 bias 180.0 A prediction for one flat, BY HAND: 2.00 x 55.0 + 40.60 x 7.5 + 1.43 x -28.0 + 1.00 x 90.0 + 180 = 644.46
Now the same calculation in one operation:
pred_dot = x @ w + b # @ is the dot-product operator print(f"x @ w + b = {pred_dot:.2f}") print(f"By hand : {pred_by_hand:.2f}") print(f"The same : {np.isclose(pred_dot, pred_by_hand)}")
x @ w + b = 644.46 By hand : 644.46 The same : True
That is the dot product. In maths:
This formula repeats through the whole book
- Linear regression (Chapter 15):
y = x @ w + b, exactly this - Logistic regression (Chapter 16):
sigmoid(x @ w + b), one function on top - Neural network (Chapter 30):
activation(x @ W + b), the same operation repeated over many layers - Transformer (Chapter 36): there's a dot product inside attention too
In other words you're learning one operation right now, and it lasts to the end of the book.
For the whole table at once: matrix multiplication
We predicted one flat. What about all 1200? Do we write a loop?
import time # Way 1: with a loop t0 = time.perf_counter() preds_loop = [] for i in range(len(X)): s = 0.0 for j in range(X.shape[1]): s += X[i, j] * w[j] preds_loop.append(s + b) loop_time = time.perf_counter() - t0 preds_loop = np.array(preds_loop) # Way 2: matrix multiplication t0 = time.perf_counter() preds_vec = X @ w + b vec_time = time.perf_counter() - t0 print(f"With a loop : {loop_time*1000:.2f} ms") print(f"With X @ w : {vec_time*1000:.4f} ms") print(f"Same results: {np.allclose(preds_loop, preds_vec)}") print(f"Speed-up : {loop_time/max(vec_time, 1e-9):.0f}x")
With a loop : 0.87 ms With X @ w : 0.0120 ms Same results: True Speed-up : 73x
This speed-up is from MY computer
The "x" number above will be different for you: the processor, the numpy version and the load all affect it.
But the direction is always the same: a vectorised operation is far faster than a loop. The reason: numpy's core is written in C and uses the processor's SIMD instructions, while a Python loop goes through the interpreter at every step.
The rule: if you see a for loop in ML code, be suspicious. There is almost always a vectorised way.
print(f"X shape : {X.shape}") print(f"w shape : {w.shape}") print(f"X @ w shape : {(X @ w).shape} -> one number per row") print() print("First 5 predictions:", (X @ w + b)[:5].round(1)) print("Actual prices :", df.narx_usd.head(5).to_numpy())
X shape : (1200, 4) w shape : (4,) X @ w shape : (1200,) -> one number per row First 5 predictions: [ 644.5 679.2 1292.5 892. 509.8] Actual prices : [ 760 550 1415 985 550]
I wrote the weights by hand, so the predictions are close but not exact. In Chapter 13 the model finds them itself.
The shape rule: the one condition of matrix multiplication
A = np.array([[1., 2., 3.], [4., 5., 6.]]) # 2x3 B = np.array([[1., 0.], [0., 1.], [1., 1.]]) # 3x2 print(f"A shape: {A.shape}") print(f"B shape: {B.shape}") print(f"A @ B : {(A @ B).shape} <- the 3 and the 3 MATCHED, the result is 2x2") print(A @ B) print() try: B @ A @ B @ A except ValueError as e: pass try: _ = A @ A except ValueError as e: print("A @ A raises an error:", str(e)[:62]) print() print("RULE: (n x m) @ (m x k) = (n x k). The two middle numbers MUST match.")
A shape: (2, 3) B shape: (3, 2) A @ B : (2, 2) <- the 3 and the 3 MATCHED, the result is 2x2 [[ 4. 5.] [10. 11.]] A @ A raises an error: matmul: Input operand 1 has a mismatch in its core dimension 0 RULE: (n x m) @ (m x k) = (n x k). The two middle numbers MUST match.
Transpose: turning the shape
print(f"X shape : {X.shape}") print(f"X.T shape : {X.T.shape} <- rows and columns swapped") print() print("Why you need it: to compute how columns relate to each other") Xm = X - X.mean(axis=0) # centring cov = (Xm.T @ Xm) / (len(X) - 1) # covariance matrix print(f"Xm.T @ Xm shape: {cov.shape} <- 4x4, one number per pair of columns") print() print("Correlation (normalised from the covariance):") std = np.sqrt(np.diag(cov)) corr = cov / np.outer(std, std) print(pd.DataFrame(corr.round(3), index=num, columns=num).to_string())
X shape : (1200, 4)
X.T shape : (4, 1200) <- rows and columns swapped
Why you need it: to compute how columns relate to each other
Xm.T @ Xm shape: (4, 4) <- 4x4, one number per pair of columns
Correlation (normalised from the covariance):
xonalar maydon_m2 metro_km mebel
xonalar 1.000 0.940 0.036 0.013
maydon_m2 0.940 1.000 0.050 0.014
metro_km 0.036 0.050 1.000 -0.021
mebel 0.013 0.014 -0.021 1.000
The 0.94 between xonalar and maydon_m2 is the number we saw on the heatmap in Chapter 4, now computed by hand.
X.T @ X is the heart of PCA and of the normal equation
This expression appears twice more in the book: - In Chapter 14, in the normal equation:
- In Chapter 27, in PCA: the eigenvectors of the covariance matrix
Both start from exactly this X.T @ X. That's why it pays to understand it now.
Broadcasting: numpy's quiet helper
print("Subtracting each column's mean from it, NO LOOP NEEDED:") print(f" X shape : {X.shape}") print(f" X.mean(axis=0) : {X.mean(axis=0).shape} -> 4 numbers") centred = X - X.mean(axis=0) print(f" X - mean result : {centred.shape} <- numpy SPREAD the 4 numbers over 1200 rows") print() print(f" Check: the new means: {centred.mean(axis=0).round(10)}") print() print("This is broadcasting. It is exactly what happens inside StandardScaler:") scaled = (X - X.mean(axis=0)) / X.std(axis=0) print(f" mean: {scaled.mean(axis=0).round(6)}") print(f" std : {scaled.std(axis=0).round(6)}")
Subtracting each column's mean from it, NO LOOP NEEDED: X shape : (1200, 4) X.mean(axis=0) : (4,) -> 4 numbers X - mean result : (1200, 4) <- numpy SPREAD the 4 numbers over 1200 rows Check: the new means: [ 0. 0. 0. -0.] This is broadcasting. It is exactly what happens inside StandardScaler: mean: [ 0. 0. 0. -0.] std : [1. 1. 1. 1.]
The StandardScaler you used in Chapter 5 is a two-line operation. Now you know what's inside it.
Broadcasting also makes silent mistakes
a = np.array([1, 2, 3]) # shape (3,) b = np.array([[1], [2], [3]]) # shape (3, 1) a + b # NO ERROR -> returns a 3x3 matrix!
You expected a 3-element result and got 9 elements. The code runs, and the result is wrong.
So: check shape after every operation, especially in calculations you wrote yourself.
a = np.array([1, 2, 3]) bb = np.array([[1], [2], [3]]) print(f"a shape : {a.shape}") print(f"b shape : {bb.shape}") print(f"a + b shape : {(a + bb).shape} <- you expected 3 numbers, you got 9") print(a + bb)
a shape : (3,) b shape : (3, 1) a + b shape : (3, 3) <- you expected 3 numbers, you got 9 [[2 3 4] [3 4 5] [4 5 6]]
The norm: a vector's "length"
For regularisation (Chapter 17) we need one idea: how "big" is a vector?
v = np.array([3.0, 4.0]) print(f"v = {v}") print() print(f"L2 norm (Euclidean) : {np.linalg.norm(v, 2):.4f} = sqrt(3^2 + 4^2)") print(f"L1 norm (Manhattan) : {np.linalg.norm(v, 1):.4f} = |3| + |4|") print() print("For our weights:") print(f" w = {w}") print(f" L2 norm: {np.linalg.norm(w, 2):.2f}") print(f" L1 norm: {np.linalg.norm(w, 1):.2f}")
v = [3. 4.] L2 norm (Euclidean) : 5.0000 = sqrt(3^2 + 4^2) L1 norm (Manhattan) : 7.0000 = |3| + |4| For our weights: w = [ 55. 7.5 -28. 90. ] L2 norm: 109.39 L1 norm: 180.50
show(pd.DataFrame([ {"Norm": "L2 (square root of the sum of squares)", "Formula": "sqrt(sum(w^2))", "Where it's used": "Ridge regression: penalises large weights"}, {"Norm": "L1 (sum of absolute values)", "Formula": "sum(|w|)", "Where it's used": "Lasso: pushes weights EXACTLY to zero"}, ]))
| Norm | Formula | Where it's used |
|---|---|---|
| L2 (square root of the sum of squares) | sqrt(sum(w^2)) | Ridge regression: penalises large weights |
| L1 (sum of absolute values) | sum(|w|) | Lasso: pushes weights EXACTLY to zero |
In Chapter 17 these two norms give two kinds of regularisation, and the reason Lasso selects features while L2 doesn't comes straight from this difference.
What we learned today
show(pd.DataFrame([ {"Concept": "Vector", "In code": "x = np.array([...])", "In ML": "One sample (one flat)"}, {"Concept": "Matrix", "In code": "X.shape = (n, m)", "In ML": "n samples, m features"}, {"Concept": "Dot product", "In code": "x @ w + b", "In ML": "ONE prediction: the most important operation"}, {"Concept": "Matrix multiplication", "In code": "X @ w + b", "In ML": "ALL the predictions at once"}, {"Concept": "The shape rule", "In code": "(n,m) @ (m,k) = (n,k)", "In ML": "Half of all mistakes live here"}, {"Concept": "Transpose", "In code": "X.T", "In ML": "X.T @ X: the normal equation and PCA"}, {"Concept": "Broadcasting", "In code": "X - X.mean(axis=0)", "In ML": "What's inside StandardScaler"}, {"Concept": "L1 / L2 norm", "In code": "np.linalg.norm(w, 1/2)", "In ML": "Lasso and Ridge (Chapter 17)"}, ]))
| Concept | In code | In ML |
|---|---|---|
| Vector | x = np.array([...]) | One sample (one flat) |
| Matrix | X.shape = (n, m) | n samples, m features |
| Dot product | x @ w + b | ONE prediction: the most important operation |
| Matrix multiplication | X @ w + b | ALL the predictions at once |
| The shape rule | (n,m) @ (m,k) = (n,k) | Half of all mistakes live here |
| Transpose | X.T | X.T @ X: the normal equation and PCA |
| Broadcasting | X - X.mean(axis=0) | What's inside StandardScaler |
| L1 / L2 norm | np.linalg.norm(w, 1/2) | Lasso and Ridge (Chapter 17) |
Next chapter: I wrote the weights by hand. How does the model find them itself?
Questions about this chapter?
The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.