datarekha

Linear algebra with np.linalg

solve, inv, det, norm, eig, svd, qr, pinv — the linear algebra toolbox you need for regression, PCA, and recommender systems.

8 min read Advanced NumPy Lesson 12 of 14

What you'll learn

  • Why np.linalg.solve beats np.linalg.inv for solving Ax = b
  • Eigendecomposition vs SVD — when each applies
  • Pseudo-inverse for non-square systems

Before you start

The last lesson built linear transformations with @; this one runs them in reverse. np.linalg is where NumPy keeps the heavy machinery — the routines that solve Ax = b, decompose a matrix into its hidden axes, and back every regression, PCA, and recommender system. You don’t need to re-derive the math (the math-for-ml chapter did that); you need to know which function to reach for, and the one habit that separates working code from textbook code: never literally invert a matrix.

solve > inv (almost always)

The textbook writes x = A⁻¹ b to solve Ax = b. Don’t put that in code.

import numpy as np

# A small system: 3 equations, 3 unknowns
A = np.array([[3., 1., 2.],
              [1., 4., 1.],
              [2., 1., 5.]])
b = np.array([8., 10., 14.])

# The textbook way — slower and numerically worse
x_inv = np.linalg.inv(A) @ b

# The right way — faster, more accurate
x_solve = np.linalg.solve(A, b)

print("inv:   ", x_inv)
print("solve: ", x_solve)
print("residual (inv):   ", np.linalg.norm(A @ x_inv - b))
print("residual (solve): ", np.linalg.norm(A @ x_solve - b))
inv:    [0.6 1.8 2.2]
solve:  [0.6 1.8 2.2]
residual (inv):    0.0
residual (solve):  2.5121479338940403e-15

solve uses an LU decomposition under the hood — roughly half the work of computing inv and then multiplying. Here both land on the same answer [0.6, 1.8, 2.2] with residuals at the floating-point floor (0 and ~2.5e-15), because this 3×3 is tiny and well-conditioned. The gap opens up when A is large or ill-conditioned: inv’s extra round-trip through an explicit inverse amplifies round-off, often by orders of magnitude, while solve stays tight. Same answer here; very different behaviour at scale.

Linear regression via the normal equations

The “normal equations” give the least-squares fit β = (XᵀX)⁻¹ Xᵀy. We solve them properly — without ever calling inv.

import numpy as np

rng = np.random.default_rng(0)

# Synthetic data: y = 2*x1 + 3*x2 + 1 + noise
n = 200
X = rng.standard_normal((n, 2))
y = 2 * X[:, 0] + 3 * X[:, 1] + 1 + rng.standard_normal(n) * 0.1

# Add an intercept column
X_design = np.column_stack([X, np.ones(n)])

# Normal equations: (X'X) beta = X'y
# Solve for beta without ever inverting
XtX = X_design.T @ X_design
Xty = X_design.T @ y
beta = np.linalg.solve(XtX, Xty)

print(f"beta1 = {beta[0]:.3f}  (true 2.000)")
print(f"beta2 = {beta[1]:.3f}  (true 3.000)")
print(f"intercept = {beta[2]:.3f}  (true 1.000)")
beta1 = 1.995  (true 2.000)
beta2 = 3.012  (true 3.000)
intercept = 1.000  (true 1.000)

That is the entire fit — and it recovered the true slopes (2, 3) and intercept (1) to within the noise. sklearn.linear_model.LinearRegression is doing essentially this, plus some checks and a sturdier solver path.

Determinant and norm

det measures how much a matrix scales volumes — zero determinant means the matrix is singular (no inverse). norm measures magnitude: for vectors the Euclidean length, for matrices the Frobenius norm by default.

import numpy as np

A = np.array([[2., 1.],
              [1., 3.]])

print("det A =", np.linalg.det(A))         # nonzero → invertible
print("||A|| =", np.linalg.norm(A))         # Frobenius
print("||A||_2 =", np.linalg.norm(A, 2))    # spectral (largest singular value)

# Vector norm
v = np.array([3., 4.])
print("||v||  =", np.linalg.norm(v))        # 5.0
print("||v||_1 =", np.linalg.norm(v, 1))    # sum of |components|
det A = 5.000000000000001
||A|| = 3.872983346207417
||A||_2 = 3.6180339887498953
||v||  = 5.0
||v||_1 = 7.0

det A = 5 (nonzero, so invertible); the classic 3-4-5 triangle gives ||v|| = 5.0 while the L1 norm sums to 7.0. (The trailing …001 on the determinant is floating point’s “5” — the numerical-stability lesson again.)

Eigendecomposition

An eigenvalue λ and its eigenvector v satisfy A @ v = λ·v: the matrix only scales v, never rotates it. eig returns all such pairs. For a symmetric / Hermitian matrix use eigh — it exploits the symmetry for speed and is guaranteed to return real (not complex) values:

import numpy as np

# A symmetric matrix (e.g. a covariance matrix)
A = np.array([[4., 1., 0.],
              [1., 3., 1.],
              [0., 1., 2.]])

vals, vecs = np.linalg.eigh(A)
print("eigenvalues:", vals)
print("eigenvectors (columns):\n", vecs)

# Sanity check: A @ v == lambda * v
v0 = vecs[:, 0]
print("A @ v0:   ", A @ v0)
print("lam0 * v0:", vals[0] * v0)
eigenvalues: [1.26794919 3.         4.73205081]
eigenvectors (columns):
 [[ 0.21132487  0.57735027 -0.78867513]
 [-0.57735027 -0.57735027 -0.57735027]
 [ 0.78867513 -0.57735027 -0.21132487]]
A @ v0:    [ 0.26794919 -0.73205081  1.        ]
lam0 * v0: [ 0.26794919 -0.73205081  1.        ]

The sanity check is the whole definition made concrete: A @ v0 and λ₀ · v0 print the same vector, so multiplying by A did nothing but stretch v0 by λ₀ ≈ 1.268. Eigendecomposition only works on square matrices; for everything else you reach for SVD.

SVD — the workhorse

SVD factors any matrix as A = U Σ Vᵀ. It powers PCA, recommender systems, image compression, and least-squares solvers. If you learn one decomposition, learn this one.

import numpy as np

rng = np.random.default_rng(7)

# A rank-deficient matrix — singular values reveal the rank
A = rng.standard_normal((5, 3)) @ rng.standard_normal((3, 8))
print("A shape:", A.shape)

U, s, Vt = np.linalg.svd(A, full_matrices=False)
print("U:", U.shape, "s:", s.shape, "Vt:", Vt.shape)
print("singular values:", s.round(3))
A shape: (5, 8)
U: (5, 5) s: (5,) Vt: (5, 8)
singular values: [6.29  3.527 1.4   0.    0.   ]

Five singular values, but only three are nonzero — exactly the rank of a (5,3) @ (3,8) product, which can be at most 3. The two zeros are the SVD announcing “there is no real structure here.” Keep the top k and you get a low-rank approximation:

import numpy as np

rng = np.random.default_rng(7)
A = rng.standard_normal((5, 3)) @ rng.standard_normal((3, 8))

U, s, Vt = np.linalg.svd(A, full_matrices=False)

# Rank-2 approximation
k = 2
A_approx = U[:, :k] @ np.diag(s[:k]) @ Vt[:k, :]
print("error (Frobenius):", np.linalg.norm(A - A_approx))
# By the Eckart-Young theorem, this equals sqrt(sum of s[k:]**2)
print("sqrt(sum of dropped s²):", np.sqrt(np.sum(s[k:]**2)))
error (Frobenius): 1.399598152527813
sqrt(sum of dropped s²): 1.399598152527813

The two numbers match to the last digit — that is the Eckart–Young theorem: the error of the best rank-k approximation is exactly the norm of the singular values you dropped. This is truncated SVD, the technique behind Netflix-era recommenders: factor the (user × movie) matrix, keep the top k components.

QR and pinv

qr factors A = QR with Q orthogonal and R upper-triangular — least squares with good stability and less cost than SVD. pinv is the Moore–Penrose pseudo-inverse, the right tool for non-square or singular matrices where inv would simply fail:

import numpy as np

# A non-square matrix — np.linalg.inv would fail
A = np.array([[1., 2.],
              [3., 4.],
              [5., 6.]])

A_pinv = np.linalg.pinv(A)
print("pinv shape:", A_pinv.shape)         # (2, 3)
print("A_pinv @ A =\n", (A_pinv @ A).round(6))   # ≈ identity
pinv shape: (2, 3)
A_pinv @ A =
 [[ 1. -0.]
 [ 0.  1.]]

The (3, 2) matrix has no true inverse, but its (2, 3) pseudo-inverse satisfies A⁺A = I — a left inverse. For least squares specifically, np.linalg.lstsq(A, b) wraps this up with extra diagnostics; prefer it when you are actually solving Ax ≈ b in the over- or under-determined case.

In one breath

np.linalg is the heavy linear-algebra toolbox. Always solve(A, b), never inv(A) @ b — LU-based, ~half the work and far stabler when A is ill-conditioned (the regression demo fits the normal equations this way, recovering slopes 2, 3 and intercept 1). det flags singularity (zero = no inverse), norm measures magnitude (Frobenius for matrices, Euclidean/L1/L2 for vectors). eig/eigh give eigenpairs A v = λ v (use eigh for symmetric matrices → real values); svd factors any matrix A = UΣVᵀ, its singular values exposing the rank (three nonzero here), and a truncated top-k SVD is the optimal low-rank approximation (Eckart–Young). For non-square/singular systems, pinv / lstsq.

Practice

Quick check

0/2
Q1You need to solve `Ax = b`. Which is best?
Q2What does SVD give you that eigendecomposition doesn't?

A question to carry forward

Notice how many named operations have piled up across this chapter and the last — @, np.dot, inner, outer, trace, transpose, solve, batched matmul. They feel like a dozen separate tools, but squint and they are all the same primitive move: multiply elements, then sum over the axes the two operands share. Matmul sums over the inner axis; trace sums over a repeated axis; an outer product sums over nothing at all.

Is there a single notation that captures every one of them — and lets you write a batched, multi-head attention score in one readable line instead of a thicket of transposes and reshapes? There is, and physicists have used it for a century. The next lesson is np.einsum: one rule — repeated indices sum, free indices stay — that spells matmul, transpose, trace, and batched contraction all in the same breath.

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
When should you use gradient descent over the normal equation to fit a linear regression?

The normal equation gives an exact closed-form solution in O(p³) time but becomes impractical when the number of features p is large (typically above ~10,000) because matrix inversion is cubic. Gradient descent scales as O(np) per iteration, making it the only viable option for large feature spaces or online learning.

What is PCA, when should you use it, and what are its key limitations?

PCA finds the orthogonal directions of maximum variance in the data and projects onto a lower-dimensional subspace, reducing features while retaining most information. It is most useful before distance-based models or when training is bottlenecked by dimensionality. Its main limits are loss of interpretability, sensitivity to scale, and an assumption of linear structure.

How does PCA work, and how do you choose the number of components?

PCA finds orthogonal directions (principal components) of maximum variance by computing the eigenvectors of the covariance matrix, then projects data onto the top components. Choose the number of components by the cumulative explained variance ratio (e.g. enough to retain 95%), a scree-plot elbow, or downstream task performance. Always standardize features first, since PCA is variance-driven.

How do L1 and L2 regularization affect bias and variance, and when would you pick one over the other?

Both L1 and L2 add a penalty on coefficient size that increases bias slightly but reduces variance, combating overfitting. L2 (ridge) shrinks all coefficients smoothly and handles correlated features well; L1 (lasso) drives some coefficients exactly to zero, performing feature selection. Choose L1 when you want sparsity and interpretability, L2 when you want stability, and elastic net to get both.

Related lessons

Explore further

Skip to content