Part 7 · Scientific Computing & Data Science · Module 49

Linear Algebra with NumPy & SciPy

Solving equations, fitting lines to noisy data, compressing images, finding the main directions in a dataset, and storing a social network with a billion links: all of these are the same handful of linear-algebra ideas. This module builds each one from a picture, then hands the arithmetic to NumPy and SciPy.

⏱ ~3 hoursPrerequisites: M47, M48

Systems of linear equations, as pictures

A classic puzzle: "Two coffees and one muffin cost $7; one coffee and three muffins cost $11. What does each cost?" Calling the coffee price x and the muffin price y:

2x +  y =  7
 x + 3y = 11

Each equation is a straight line in the (x, y) plane: all the price pairs that satisfy it. The answer is the point that lies on both lines, where they cross. There are only three possible pictures for two lines:

one solution det ≠ 0, rank 2 no solution parallel: det = 0 infinitely many same line: det = 0
Two equations in two unknowns: the lines cross once, never, or everywhere. With three unknowns, each equation is a plane, and the same three cases apply.

Stack the coefficients into a matrix and the unknowns into a vector, and the whole system becomes one matrix-vector equation, using the multiplication from M47:

A = [[2, 1],     x = [x,     b = [ 7,        A @ x == b
     [1, 3]]          y]          11]

There's also a column picture: A x = x·(2, 1) + y·(1, 3), so solving means "find how much of each column to mix to reach b". That reading is the one that generalizes to least squares later.

Step through a matrix product below to refresh the "row dot column" rule that A @ x uses.

Remember: each linear equation is a line (or plane); the solution is where they all meet; A x = b packages the whole system.

solve, not inverse

For numbers, you solve 3x = 12 by dividing: x = 12 / 3. Matrices have an analogue of "divide by": the inverse A⁻¹, the matrix that undoes A (A⁻¹A = I). Then x = A⁻¹ b. Textbooks write that; working code almost never computes it. Use np.linalg.solve(A, b) instead, for three reasons:

The condition number np.linalg.cond(A) measures how sensitive the answer is to tiny changes in A or b. Roughly, you lose about log10(cond) of your ~16 significant digits. A condition number of 10¹² leaves about 4 trustworthy digits, whichever method you use.

Remember: x = np.linalg.solve(A, b). Treat inv as a code smell, and check cond when results look odd.

Determinant and rank: does the matrix squash space?

Recall from M47 that a 2 × 2 matrix transforms the plane, sending the unit square (area 1) to a parallelogram whose sides are the matrix's columns. The determinant det(A) is the area of that parallelogram: the factor by which the transformation scales areas. For [[a, b], [c, d]] it is ad - bc. (In 3-D it scales volumes.)

area 6 [[3, 0], [0, 2]] det = 3·2 − 0 = 6 area 1 shear [[1, 1], [0, 1]] det = 1 area 0 [[1, 2], [1, 2]] det = 1·2 − 2·1 = 0
The determinant is the signed area of the image of the unit square. Zero means the plane was flattened onto a line: information is lost and the matrix has no inverse.

Rank is the number of genuinely independent directions the output fills: the dimension of the space you can reach. A 3 × 3 matrix with rank 3 fills all of space; rank 2 flattens it to a plane; rank 1 to a line. Equivalently, it's the number of linearly independent columns (columns that can't be made by mixing the others). Rank is the honest version of "is it singular?", because np.linalg.matrix_rank uses a tolerance, while a determinant can be tiny for perfectly healthy matrices (det(0.1 · I₁₀₀) = 10⁻¹⁰⁰).

Remember: det = area (volume) scale factor, sign = orientation flip, zero = squashed and non-invertible. Rank counts independent directions; prefer it to "det == 0" tests.

Norms: different ways to measure size

M47's length √(Σ xᵢ²) is one way to measure a vector's size. A norm is any such size measure that behaves sensibly (zero only for the zero vector, scales with the vector, and obeys the triangle inequality |v + w| ≤ |v| + |w|). Three norms do most of the work:

L2 (Euclidean)

√(Σ xᵢ²). Straight-line distance. The default in np.linalg.norm. Used for least squares and most geometry.

L1 (Manhattan)

Σ |xᵢ|. Distance walking along city blocks. Less sensitive to outliers; L1 penalties (lasso) push model weights to exactly zero.

L∞ (max)

max |xᵢ|. The worst single component. Used for tolerances ("no reading is off by more than 0.01").

L∞ = 1 (square) L2 = 1 (circle) L1 = 1 (diamond)
All the points at distance 1 from the origin, under each norm. Same idea, different shape of "nearby".

Remember: L2 for distance, L1 for robustness and sparsity, L∞ for worst-case error.

Projections and least squares

Projection: the closest point on a line

In M47 you saw the "shadow" of w on v. The shadow, as a vector, is the projection of w onto the line through v:

proj = (w @ v) / (v @ v) * v          # a multiple of v
residual = w - proj                   # what's left over; perpendicular to v

The projection is the point on the line closest to w, and the leftover (residual) is perpendicular to the line. "Closest point = drop a perpendicular" is the key geometric fact behind least squares.

Least squares: when there are more equations than unknowns

Suppose you measured study hours and exam scores for 30 students and want a line score ≈ m·hours + c. That's 30 equations (one per student) but only 2 unknowns (m and c). Real data is noisy, so no line passes through every point: the system is overdetermined and has no exact solution. Least squares picks the line that minimizes the sum of squared vertical errors Σ (yᵢ - (m xᵢ + c))².

In matrix form, the design matrix X has a column of x-values and a column of 1s, and we want X @ [m, c] ≈ y. Geometrically, the columns of X span a flat plane inside 30-dimensional space; y sticks out of it; the best fit is the projection of y onto that plane. np.linalg.lstsq(X, y) computes it stably (via the SVD, below). You may see the "normal equations" (XᵀX) β = Xᵀy in textbooks; they give the same answer but square the condition number, so prefer lstsq.

Remember: projection = closest point, residual is perpendicular. Least squares projects y onto the column space of X; use np.linalg.lstsq.

Eigenvalues and eigenvectors: directions that only stretch

Apply a matrix to most vectors and they change direction. But some special directions are only stretched (or shrunk, or flipped): the output points along the same line as the input. Those are eigenvectors ("eigen" is German for "own" or "characteristic"), and the stretch factor is the eigenvalue λ:

A @ v == λ * v        # v is an eigenvector, λ its eigenvalue

For A = [[2, 1], [1, 2]], the direction (1, 1) is stretched by 3, and (1, -1) is left alone (stretch 1). Every other direction gets pulled toward (1, 1), the direction with the largest eigenvalue. The plot below shows it: gray arrows are unit vectors, colored arrows are where A sends them, and the eigen-directions are the only ones that don't turn.

Why care? Eigenvectors reveal what a matrix "does" in its most natural coordinates:

For symmetric matrices (A == A.T, like covariance matrices), the eigenvalues are real and the eigenvectors are perpendicular. Use np.linalg.eigh for them: it is faster, more accurate, and returns eigenvalues sorted ascending. A general matrix can have complex eigenvalues: a rotation, for example, turns every direction, so it has no real eigenvectors.

Remember: A v = λ v: eigenvectors keep their direction, eigenvalues are the stretch. Use eigh for symmetric matrices.

The SVD and low-rank approximation

Eigenvectors only work for square matrices, and not even all of those. The singular value decomposition (SVD) works for every matrix, any shape. It says that any linear transformation is, in the right coordinates, just three simple steps:

A = U @ diag(s) @ Vt
#   rotate  ·  stretch along axes  ·  rotate
#   U, Vt: orthogonal (pure rotations/reflections)   s: singular values, s[0] ≥ s[1] ≥ … ≥ 0
Vᵀ → diag(s) → then U rotates: circle → tilted ellipse unit circle s₀
Every matrix turns the unit circle into an ellipse. The singular values are the ellipse's semi-axis lengths; U and Vᵀ are the rotations.

The SVD can also be read as a sum of simple pieces: A = s₀ u₀ v₀ᵀ + s₁ u₁ v₁ᵀ + …, where each uᵢ vᵢᵀ is a rank-1 matrix (an outer product, as in M47). The pieces are ordered by importance. Keeping only the first k gives the best possible rank-k approximation of A (the Eckart–Young theorem): no other rank-k matrix is closer. That is the idea behind image compression, noise removal, recommender systems, and PCA.

Storage tells the story. An m × n image needs m·n numbers; a rank-k approximation needs only k·(m + n + 1). For a 200 × 300 image and k = 20, that's 60,000 vs 10,020: about 6× smaller.

The singular-value plot is the diagnostic: a few big values followed by a long flat tail means the image (or dataset) is "really" low-rank plus noise. Cutting the tail compresses and denoises.

Remember: A = U diag(s) Vᵀ for any matrix: rotate, stretch, rotate. Truncating to the top k singular values gives the best rank-k approximation.

PCA from scratch via the SVD

Principal component analysis finds the directions along which data varies the most. Picture a cloud of points shaped like a tilted cigar: the first principal component points along the cigar, the second across it. Projecting onto the first few components keeps most of the information with fewer numbers, which is useful for visualization (squash 50 features into a 2-D scatter plot), compression, and speeding up models.

The recipe, with data X of shape (n_samples, n_features):

  1. Center: subtract each column's mean, so the cloud sits at the origin (broadcasting from M48).
  2. SVD: U, s, Vt = np.linalg.svd(Xc, full_matrices=False). The rows of Vt are the principal directions, already sorted by importance.
  3. Variance explained: the variance along component i is s[i]**2 / (n - 1); divide by the total for the explained variance ratio.
  4. Project: Z = Xc @ Vt[:k].T gives the new k-dimensional coordinates. Reconstruct approximately with Z @ Vt[:k] + mean.

(Equivalently, the principal directions are the eigenvectors of the covariance matrix XcᵀXc / (n - 1); the SVD gets them without forming that matrix, which is more accurate.)

Remember: center, SVD, keep the top rows of Vt; s²/(n-1) are the variances; project with Xc @ Vt[:k].T.

scipy.linalg: factorizations you can reuse

scipy.linalg contains everything in np.linalg plus more, always built against LAPACK. Its main extra value is factorizations: splitting a matrix into simpler pieces once, then reusing them.

Remember: factor once, solve many times. LU for general square systems, Cholesky for symmetric positive-definite, triangular solves are O(n²).

Sparse matrices: storing only what's there

Many real matrices are almost entirely zeros. A friendship graph of 1 million people where each has about 200 friends has 10¹² possible entries but only 2 × 10⁸ ones: 99.98% zeros. Stored densely as float64, that's 8 TB; stored sparsely, a few gigabytes. Machine-learning feature matrices are similar: one-hot encoded categories and bag-of-words text produce rows with a handful of non-zeros among tens of thousands of columns. A sparse matrix stores only the non-zero entries and their positions.

Formats

000 0000 00000 00 1020 30 405060 c0c1c2c3c4 r0r1r2r3 data = [ 10, 20,30,40, 50, 60] indices = [ 0, 4,1,0, 2, 3] indptr = [0, 2, 3, 3, 6] row i lives in data[indptr[i] : indptr[i+1]] row 0 → 0:2 row 1 → 2:3 row 2 → 3:3 (empty) row 3 → 3:6 Dense: 20 values. CSR: 6 values + 6 column indices + 5 row pointers. For an n×n matrix with z non-zeros: dense O(n²) memory, CSR O(z + n). Matrix-vector product: dense O(n²) work, CSR O(z).
CSR stores the non-zeros row by row. indptr has one more entry than there are rows; consecutive differences are the row lengths.

Why machine learning loves sparse matrices

Remember: store only non-zeros. Build in COO, compute in CSR (rows) or CSC (columns). Sparse mat-vec costs O(nnz), not O(n²).

Practice

Use NumPy (and SciPy where asked). Tests compare with tolerances, and eigenvector/SVD signs are not assumed.

Write solve_system(A, b) that returns the solution of A x = b using np.linalg.solve. If A is not square, or its rank is less than its size, raise ValueError with a message containing "singular" or "square". Also write residual(A, x, b) returning the L2 norm of A x - b.

Hint

Check A.ndim == 2 and A.shape[0] == A.shape[1], then np.linalg.matrix_rank(A) < A.shape[0].

Show solution
import numpy as np

def solve_system(A, b):
    A = np.asarray(A, dtype=float)
    if A.ndim != 2 or A.shape[0] != A.shape[1]:
        raise ValueError(f"A must be square, got shape {A.shape}")
    if np.linalg.matrix_rank(A) < A.shape[0]:
        raise ValueError("A is singular: no unique solution")
    return np.linalg.solve(A, b)

def residual(A, x, b):
    return float(np.linalg.norm(A @ x - b))

Write fit_poly(x, y, degree) that returns the least-squares coefficients [c0, c1, ..., c_degree] of y ≈ c0 + c1·x + c2·x² + … using np.linalg.lstsq on a design matrix you build yourself (no np.polyfit). Then write predict(coeffs, x).

Hint

The design matrix has columns x**0, x**1, …, x**degree: np.column_stack([x ** p for p in range(degree + 1)]) or np.vander(x, degree + 1, increasing=True).

Show solution
import numpy as np

def fit_poly(x, y, degree):
    x = np.asarray(x, dtype=float)
    X = np.column_stack([x ** p for p in range(degree + 1)])
    coeffs, *_ = np.linalg.lstsq(X, y, rcond=None)
    return coeffs

def predict(coeffs, x):
    x = np.asarray(x, dtype=float)
    return sum(c * x ** p for p, c in enumerate(coeffs))

Write power_iteration(A, iters=500, tol=1e-12) returning (eigenvalue, unit_eigenvector) for the eigenvalue of largest absolute value of a square matrix. Start from a vector of ones, repeatedly multiply and normalize, and estimate the eigenvalue with the Rayleigh quotient v @ A @ v. Stop early when the eigenvalue estimate changes by less than tol.

Hint

Inside the loop: w = A @ v, v = w / np.linalg.norm(w), lam_new = v @ A @ v. Compare lam_new with the previous estimate.

Show solution
import numpy as np

def power_iteration(A, iters=500, tol=1e-12):
    A = np.asarray(A, dtype=float)
    v = np.ones(A.shape[0]) / np.sqrt(A.shape[0])
    lam = v @ A @ v
    for _ in range(iters):
        w = A @ v
        v = w / np.linalg.norm(w)
        lam_new = v @ A @ v
        if abs(lam_new - lam) < tol:
            lam = lam_new
            break
        lam = lam_new
    return float(lam), v

Write low_rank(A, k) returning the best rank-k approximation of A using np.linalg.svd, and storage_ratio(shape, k) returning k * (m + n + 1) / (m * n). Then write rank_for_energy(A, fraction) returning the smallest k such that the top k singular values hold at least fraction of the total Σ s².

Hint

U, s, Vt = np.linalg.svd(A, full_matrices=False) then (U[:, :k] * s[:k]) @ Vt[:k]. For energy, np.cumsum(s**2) / (s**2).sum() and np.searchsorted (or argmax of a boolean).

Show solution
import numpy as np

def low_rank(A, k):
    U, s, Vt = np.linalg.svd(A, full_matrices=False)
    return (U[:, :k] * s[:k]) @ Vt[:k]

def storage_ratio(shape, k):
    m, n = shape
    return k * (m + n + 1) / (m * n)

def rank_for_energy(A, fraction):
    s = np.linalg.svd(A, compute_uv=False)
    energy = np.cumsum(s ** 2) / (s ** 2).sum()
    return int(np.argmax(energy >= fraction - 1e-12)) + 1

Write pca(X, k) returning a tuple (components, explained_ratio, Z, mean): components is (k, n_features) with unit-length rows (the top principal directions), explained_ratio is the fraction of variance each explains (length k), Z is the (n_samples, k) projection of the centered data, and mean is the column mean. Use the SVD.

Hint

Center, SVD, var = s**2 / (n - 1), ratio = var[:k] / var.sum(), components = Vt[:k], Z = Xc @ Vt[:k].T.

Show solution
import numpy as np

def pca(X, k):
    X = np.asarray(X, dtype=float)
    mean = X.mean(axis=0)
    Xc = X - mean
    _, s, Vt = np.linalg.svd(Xc, full_matrices=False)
    var = s ** 2 / (X.shape[0] - 1)
    components = Vt[:k]
    return components, var[:k] / var.sum(), Xc @ components.T, mean

Write to_csr(dense) returning a tuple (data, indices, indptr) of NumPy arrays for a 2-D array (row by row, columns ascending, skipping zeros), and csr_matvec(data, indices, indptr, x) computing A @ x using only those arrays. A Python loop over rows is fine; touch only the non-zeros.

Hint

For each row, cols = np.nonzero(row)[0]; append values and columns; push the running count onto indptr. For the product, row i is data[s:e] @ x[indices[s:e]] with s, e = indptr[i], indptr[i+1].

Show solution
import numpy as np

def to_csr(dense):
    dense = np.asarray(dense)
    data, indices, indptr = [], [], [0]
    for row in dense:
        cols = np.nonzero(row)[0]
        data.extend(row[cols])
        indices.extend(cols)
        indptr.append(len(data))
    return np.array(data, dtype=float), np.array(indices, dtype=int), np.array(indptr, dtype=int)

def csr_matvec(data, indices, indptr, x):
    n_rows = len(indptr) - 1
    out = np.zeros(n_rows)
    for i in range(n_rows):
        s, e = indptr[i], indptr[i + 1]
        out[i] = data[s:e] @ x[indices[s:e]]
    return out

Mini-project: a PCA-based compressor

Mini-projectpcazip: compress images (or tables) with PCA

Treat each row of a grayscale image as a sample with width features (or use any numeric dataset). Fit PCA, keep k components, and store only what's needed to reconstruct: the mean, the k components, and the projected coordinates.

pixi init pcazip && cd pcazip
pixi add python=3.14 numpy matplotlib pytest
pixi run python -m pcazip photo.png --energy 0.95      # writes photo.pcaz.npz and report.png
pixi run pytest -q

Spec

  • fit(X, k) returns a small frozen dataclass PCAModel(mean, components, explained_ratio) built with the SVD (reuse your exercise code).
  • encode(model, X) → Z; decode(model, Z) → approximate X.
  • choose_k(X, energy) picks the smallest k keeping that fraction of variance.
  • save(path, model, Z) / load(path) use np.savez_compressed; store Z and components as float32 to halve the size.
  • A CLI (__main__.py with argparse) compresses an image, reports original vs compressed bytes and relative error, and saves a figure: original, reconstruction, error image, and the cumulative explained-variance curve.
  • decode(model, encode(model, X)) equals X (within 1e-8) when k is the full rank (tested)
  • Reconstruction error decreases as k increases (tested on random data)
  • choose_k returns 1 for data that lies exactly on a line (tested)
  • Save/load round-trips the model and codes exactly (tested with tmp_path)
  • The CLI prints sizes and error, and writes report.png with four panels
  • pixi run pytest passes

Stretch goals

  • Compress color images by processing each channel, or by stacking channels as features.
  • Compare with plain SVD truncation of the whole image at equal storage. Which wins, and why?
  • Use scipy.sparse.linalg.svds to compute only the top k singular vectors of a large matrix, and time it against the full SVD.

Quiz

Recap

Going further