Skip to content

Vectors and matrices: the language of data

Chapter 12 14 code blocks 3220 words

The code in this chapter uses these filesrun it on your own machine

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:

  1. Explain why the model predicted exactly that
  2. Find out why the model fell over when it fails
  3. 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

python13 lines
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")
Output
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

is its first element.

The whole table is a matrix

python11 lines
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)")
Output
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:

python14 lines
# 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}")
Output
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:

python4 lines
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)}")
Output
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?

python22 lines
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")
Output
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.

python6 lines
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())
Output
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

python17 lines
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.")
Output
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

python12 lines
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())
Output
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

python12 lines
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)}")
Output
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

python3 lines
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.

python6 lines
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)
Output
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?

python10 lines
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}")
Output
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
python6 lines
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"},
]))
NormFormulaWhere 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

python10 lines
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)"},
]))
ConceptIn codeIn ML
Vectorx = np.array([...])One sample (one flat)
MatrixX.shape = (n, m)n samples, m features
Dot productx @ w + bONE prediction: the most important operation
Matrix multiplicationX @ w + bALL the predictions at once
The shape rule(n,m) @ (m,k) = (n,k)Half of all mistakes live here
TransposeX.TX.T @ X: the normal equation and PCA
BroadcastingX - X.mean(axis=0)What's inside StandardScaler
L1 / L2 normnp.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.

Write on Telegram Contents