Introduction to Machine Learning
The same idea, written twice: once in notation, once in code
Everything here is used in every lecture that follows. Most of it is used far beyond this course.
theta @ xThis session is a translation exercise. Every symbol you meet has a line of code, and every line of code has a symbol.
And a third language, the first one: the sentence you would say out loud. If you cannot say it in English, the symbols will not save you. The next slide says one idea all three ways.
import numpy as np # a library: arrays and linear algebra
x = np.array([2.0, 3.0, 1.0]) # a vector
print(x.shape) # (3,): one axis holding three numbers
A = np.array([[1, 2], [3, 4]]) # a matrix: a list of rows
print(A.shape) # (2, 2)
print(A @ A, A * A) # matrix product, then elementwise
Press Run, then change a number and predict the output before you run it again. (3,), with its trailing comma, is how Python writes a shape with one axis.
for year in range(1970, 2023):
P = P * (1 + r)One step, repeated: that is how a model steps into the future, and it is a for loop. The rate here is 1950 to 1970's average.
import pandas as pd
P = pd.read_csv('world_population.csv').set_index('year').population
r = (P[1970] / P[1950]) ** (1 / 20) - 1 # growth per year, 1950 to 1970
p = P[1970]
for year in range(1970, 2023):
p = p * (1 + r) # next year: this year, grown by r
print(f'r = {r:.2%}: the loop says {p / 1e9:.2f} billion in 2023')
print(f'the world had {P[2023] / 1e9:.2f} billion')
About 1.99%, 10.48 billion predicted, 8.09 billion real. The loop was fine; the rate changed.
Part 2 of 6
What is one example, as something you can compute with?
drag either arrow head
import numpy as np
x = np.array([2, 3, 1])
y = np.array([1, -2, 4])
print(x.shape, len(x))
# addition and scaling
print(x + y)
print(3 * x)
# the inner product
print(np.dot(x, y))
print(np.linalg.norm(x))
The inner product \( x^\top y = \sum_i x_i y_i \) is the single most common operation in this course.
drag the point: one vector, three lengths
import numpy as np
x = np.array([2, 3, 1])
y = np.array([1, -2, 4])
print(x + y, 3 * x, np.dot(x, y), np.linalg.norm(x))
A = np.array([[1, 2, 3],
[4, 5, 6]])
print(A * A) # elementwise
print(A @ A.T) # matrix product: (2, 3) times (3, 2)
Try A @ A. The error message is the shape rule speaking: the inner dimensions must match.
Part 3 of 6
What does a matrix do?
\( C = AB \): hover a cell of C to see which row and column produce it
A = np.array([[1, 2, 3],
[4, 5, 6]])
print(A.shape) # (2, 3)
print(A.size) # 6
B = np.zeros((3, 3))
C = np.ones((2, 4))
D = np.eye(3)
E = np.random.rand(2, 3)
# the two products people confuse
print(A * A) # elementwise
print(A @ A.T) # matrix product
A * B multiplies elementwise;
A @ B is the matrix product. They are different
operations, and NumPy will happily run the wrong one.
The first column is where \(\hat\imath\) lands, the second where \(\hat\jmath\) lands. Drag their tips. Every other point goes along: \(x_1\hat\imath + x_2\hat\jmath\) lands at \(x_1 A\hat\imath + x_2 A\hat\jmath = Ax\).
\(R\) turns the plane a quarter turn; \(S\) shears it. Doing one and then the other is multiplying: the machine you run first sits on the right, next to \(x\).
The two F's end up in different places: \(RS \neq SR\). Order matters for matrices, which is why \(\hat\theta = (X^\top X)^{-1} X^\top y\) has to be read carefully.
dashed: columns of \(A\) · solid: scaled by the sliders · yellow: their sum
np.linalg.solve(A, b), instead of
np.linalg.inv(A) @ b. Never invert a matrix! (as a rule of thumb)
To see a zero determinant, press "flatten" on "A matrix is a machine that moves space": the plane collapses to a line, and nothing can unflatten it.
Real measurements, 342 penguins, four features plus an intercept column. Now add one more column: bill length again, in centimetres.
X = np.column_stack([np.ones(342), bill_len, bill_dep,
flipper, mass])
np.linalg.matrix_rank(X) # 5 of 5 columns
np.linalg.cond(X.T @ X) # 1.8e+10
# the same feature, in different units
X2 = np.column_stack([X, bill_len / 10])
np.linalg.matrix_rank(X2) # 5 of 6 columns
np.linalg.cond(X2.T @ X2) # 7.1e+21
# and this does not raise
theta = np.linalg.inv(X2.T @ X2) @ X2.T @ y
np.linalg.det(X2.T @ X2) # -7.9e+09, not 0
matrix_rank and
cond, and prefer
lstsq.
Singular: no inverse. Condition number: how close to singular (1 is perfect; past 1e+16, numerically singular). lstsq: least squares without an inverse.
X = np.column_stack([np.ones(342), bill_len, bill_dep, flipper, mass])
X2 = np.column_stack([X, bill_len / 10]) # bill length again, in cm
print(np.linalg.matrix_rank(X), np.linalg.matrix_rank(X2))
print(f'{np.linalg.cond(X.T @ X):.1e} {np.linalg.cond(X2.T @ X2):.1e}')
print(f'det = {np.linalg.det(X2.T @ X2):.1e}') # not zero, and not trustworthy
theta = np.linalg.inv(X2.T @ X2) @ X2.T @ y # no error is raised
print(theta.round(2))
Rank 5, then 5 of 6. The second condition number is astronomical and the determinant, which should be zero, is a large number whose exact value depends on your machine: on a laptop 7.1e+21 and −7.9e+09, in this browser something else. That instability is the lesson. The inverse runs anyway and returns nonsense; replace the last line with np.linalg.lstsq(X2, y, rcond=None) and compare.
Part 4 of 6
How do you measure change, from a formula or from data?
Beirut's seasonal temperature: eleven years of daily means, averaged by day of the year. Real, and curved.
Zoom in. The curve straightens until it is its own tangent line. The slope of that line is the derivative: how fast the curve is changing there.
The total is the area under the rate: an integral. The slope of the total is the rate: a derivative. Each undoes the other, and that is the fundamental theorem of calculus. In NumPy they are np.cumsum and np.diff.
import json, numpy as np
d = json.load(open('beirut_weather.json'))['daily']
dates = np.array(d['time'], dtype='datetime64[D]')
rain = np.array(d['precipitation_sum']) # mm per day: a rate
year = (dates >= np.datetime64('2019-09-01')) & (dates < np.datetime64('2020-09-01'))
total = np.cumsum(rain[year]) # the integral: rain so far
print(round(total[-1])) # the whole year, in mm
print(np.allclose(np.diff(total), rain[year][1:])) # its derivative is the rate
You should see 932 and True. Change the two dates on the year line to see another rain year; then load d["temperature_2m_max"] the same way and find, with np.diff, the day with the biggest jump.
drag to see the slope
Contours of the parabolic least-squares cost. drag to see the steepest \( \nabla J \) (ascent) and \( -\nabla J \) (descent)
Least squares in matrix form, differentiated once, slowly. Here \( X \in \mathbb{R}^{m \times d} \), \( y \in \mathbb{R}^{m} \), \( \theta \in \mathbb{R}^{d} \), and \( J(\theta) \) is a single number.
Read it as a sentence: \( X\theta - y \) is the residual, and \( X^\top \) asks each feature how much of that residual it can still explain.
drag \(y\). The plane of the page stands for \( \mathbb{R}^m \), one axis per data point.
Written with an inverse; computed with np.linalg.lstsq.
class Value:
def __init__(self, v, parents=(), local=()):
self.v, self.grad = v, 0.0
self.parents, self.local = parents, local # d(self)/d(parent)
def __add__(self, o): return Value(self.v + o.v, (self, o), (1, 1))
def __sub__(self, o): return Value(self.v - o.v, (self, o), (1, -1))
def __mul__(self, o): return Value(self.v * o.v, (self, o), (o.v, self.v))
def backward(self, g=1.0):
# chain rule: multiply along each path back, add over paths
self.grad += g
for p, d in zip(self.parents, self.local):
p.backward(g * d)
x, y = Value(2.0), Value(5.0)
w, b = Value(1.0), Value(0.5)
e = w * x + b - y
L = e * e
L.backward()
print('L =', L.v, ' dL/dw =', w.grad, ' dL/db =', b.grad)
print('by hand: 2e*x =', 2 * e.v * x.v, ' 2e =', 2 * e.v)
h = 1e-6 # and by nudging w a little
print('nudge:', ((((1 + h) * 2 + 0.5 - 5) ** 2) - L.v) / h)
Run the right-hand block: it runs the class first. Both ways give −10 and −5, the red numbers on the graph. Add a __pow__, or a second example, and the same backward still works: this is what PyTorch does, at scale.
Part 5 of 6
What does the computer actually do with your arrays?
| a.shape | b.shape | a + b |
|---|---|---|
| (342, 4) | (4,) | (342, 4) |
| (342, 4) | (342, 1) | (342, 4) |
| (342, 4) | (342,) | error |
| (342,) | (342, 1) | (342, 342) |
mu = X.mean(axis=0) # (4,)
sd = X.std(axis=0) # (4,)
Z = (X - mu) / sd # (342, 4)
(m,) minus targets
of shape (m, 1) give you an
\(m \times m\) matrix, no error, and a loss that is quietly meaningless.
Print shapes when a number surprises you.
Drawn with 3 rows; in the penguin data the 3 would be 342 and nothing else changes. Solid cells are the data you have; faint ones are the copies NumPy pretends to make.
| Python loop | 138.5 ms |
a @ b |
0.76 ms |
| speedup | 182 times |
Measured on an Apple M-series laptop, NumPy 1.26, same answer to nine decimal places. A 500 by 500 matrix product takes 15.5 ms, about 16 GFLOP/s.
for i in range(len(x))
over data, there is almost always an array expression that replaces it, and it is both
faster and closer to the formula on the slide.
Loops are not the enemy: a loop over time, like the population loop at the start, is how a model steps forward. The loop to avoid is the one over the rows of your data.
import numpy as np, time
n = 1_000_000
a, b = np.random.rand(n), np.random.rand(n)
t = time.perf_counter()
s = 0.0
for i in range(n):
s += a[i] * b[i]
loop = time.perf_counter() - t
t = time.perf_counter()
s2 = a @ b
vec = time.perf_counter() - t
print(f'loop {loop * 1000:.0f} ms, a @ b {vec * 1000:.2f} ms: {loop / vec:.0f} times faster')
Python here runs inside your browser, so the loop is slower than on a laptop; the gap is the lesson. Run it twice: the second time is faster, because the first run warms things up.
Part 6 of 6
What do you look at before you model anything?
import pandas as pd
df = pd.read_csv("penguins.csv")
print(df.shape)
print(df.head(3))
print(df.describe())
print(df.isnull().sum())
# the four numeric columns, as one matrix
cols = ["bill_length_mm", "bill_depth_mm",
"flipper_length_mm", "body_mass_g"]
X = df[cols].dropna().values
print(X.shape, X.dtype)
Always describe()
and check for missing values before modelling anything. Two of these 344 penguins were never
measured, and dropna() is a decision, not a formality.
import pandas as pd
df = pd.read_csv('penguins.csv')
print(df.shape)
print(df.isnull().sum())
print(df.describe().round(1))
Two birds have no measurements and eleven have no sex recorded. dropna() is a decision, not a formality: which rows would it remove here?
Every dataset in this course arrives in this one shape: \( m \) examples down, \( d \) features across, and the thing you want to predict pulled out into its own column.
Least squares fit, in grams
Palmer Station penguins (Gorman et al.), CC0, vendored with the deck.
Anscombe's quartet, 1973
one example, one row
the dataset, and a transformation
the direction learning walks against
look at the data before you model it
Next: supervised learning and linear regression, where all of this becomes one line of code.
| English | Mathematics | Python |
|---|---|---|
| how much two vectors agree | \(x^\top y\) | x @ y |
| a matrix moves a vector | \(Ax\) | A @ x |
| the direction uphill | \(\nabla_\theta J\) | 2 * X.T @ (X @ theta - y) |
| the running total, and back | \(\int\), \(\frac{d}{dt}\) | np.cumsum, np.diff |
| one step into the future | \(P_{t+1} = (1 + r)P_t\) | P = P * (1 + r) |