Linear Algebra for Machine Learning Interviews
Every embedding, attention head and gradient update is linear algebra. Master the five questions interviewers actually ask about vectors, matrix decompositions and derivatives.
Covers: Vectors & matrices, matrix multiplication cost, eigendecomposition, SVD, norms, matrix calculus
Machine learning interviews rarely ask you to invert a matrix by hand. They ask whether you understand what the operations mean, because that understanding is what lets you debug an exploding loss, explain why an embedding table is 4 GB, or reason about why attention is .
This lesson covers the five linear-algebra questions that show up most often in ML engineer screens at product companies and AI labs. For each one you get the 30-second spoken answer, the deeper explanation, runnable code, and the follow-up the interviewer will hit you with next.
30-second answer
The dot product is the length of one vector times the projection of the other onto it: it grows with both magnitude and alignment. Cosine similarity divides that by both norms, isolating the angle. Embeddings encode meaning in direction, not length, so cosine removes the nuisance magnitude that comes from token frequency or document length.
Algebraic and geometric definitions of the dot product.
Read the right-hand side as: how long is $a$, times how long is $b$, times how aligned are they. Two of those three factors are usually noise when you compare embeddings.
In a sentence-embedding model the vector norm often correlates with how common or how long the input was — not with what it means. Two paraphrases can land in almost the same direction but at different radii. Cosine similarity strips the radii away:
This is also why almost every vector database ships an L2-normalise on write option. Once vectors are unit length, cosine similarity, inner product and (negated) squared Euclidean distance all rank identically — so you can use the fastest kernel your index supports.
import numpy as np
query = np.array([0.9, 0.1, 0.2])
short = np.array([0.9, 0.1, 0.2]) # same direction, small norm
verbose = np.array([4.5, 0.5, 1.0]) # same direction, 5x norm
noise = np.array([0.1, 0.9, 3.0]) # different direction, big norm
def cosine(a, b):
return a @ b / (np.linalg.norm(a) * np.linalg.norm(b))
for name, v in [("short", short), ("verbose", verbose), ("noise", noise)]:
print(f"{name:8s} dot={query @ v:6.2f} cos={cosine(query, v):5.2f}")
# short dot= 0.86 cos= 1.00
# verbose dot= 4.30 cos= 1.00 <- same meaning, cosine agrees
# noise dot= 0.78 cos= 0.28 <- raw dot ranks it near 'short'!Raw dot product ranks the semantically unrelated noise vector almost as highly as an exact match, purely because it is long. Cosine does not. That single failure mode is the whole answer.
Say these points
- Dot product = magnitude × magnitude × alignment.
- Embedding meaning lives in direction; norm is often a length/frequency artefact.
- After L2 normalisation, cosine / inner product / Euclidean give identical rankings.
- Serving metric must match the training objective (cosine-trained → cosine served).
Avoid these mistakes
- Saying 'cosine ignores magnitude' without explaining *why that is desirable* for text.
- Normalising at query time but not at index time (or vice versa) — a real production bug.
- Forgetting that cosine on already-normalised vectors is wasted compute; use inner product.
Expect these follow-ups
- Your recall dropped after switching embedding models but the index is unchanged. What do you check first?
- How would you decide between cosine and Euclidean for a face-recognition embedding?
- Why do many RAG systems still fail on keyword-heavy queries even with good cosine scores?
30-second answer
X @ W costs roughly 2·B·d·h FLOPs (one multiply and one add per term) and produces a B×h output. In transformers this is why the feed-forward block dominates FLOPs, why attention is O(n²·d) in sequence length, and why decoding is memory-bandwidth-bound rather than compute-bound.
Each of the output entries is a dot product over terms, i.e. multiplies plus adds. Engineers round this to 2·B·d·h FLOPs because modern GPUs execute a fused multiply-add (FMA) as one instruction pair.
| Quantity | Cost |
|---|---|
| Compute | ≈ 2·B·d·h FLOPs |
| Output memory | B·h elements |
| Weight memory | d·h elements (read every call) |
| Arithmetic intensity | ≈ 2·B·d·h / (d·h + B·d + B·h) FLOPs per element |
That last row is the one that separates candidates. Arithmetic intensity — FLOPs per byte moved — decides whether you are compute-bound or bandwidth-bound. During training, is large (thousands of tokens), intensity is high, and the GPU's tensor cores are the bottleneck. During autoregressive decoding, token: you read the entire weight matrix to do a single skinny matrix-vector product. Intensity collapses and you become memory-bandwidth-bound.
Applying it to a transformer layer
For sequence length , model width and FFN width , per layer per token:
- QKV projections: 3 × (2·n·d·d) = 6n·d² FLOPs
- Attention scores : 2·n²·d FLOPs, and an n×n score matrix per head
- Attention output (scores × V): another 2·n²·d
- Output projection: 2n·d²
- FFN (two layers): 2·n·d·4d + 2·n·4d·d = 16n·d²
Total ≈ . Attention only dominates once — for a 4096-wide model that is a ~24k token context. Below that, the FFN is where your FLOPs go, which is why MoE routing targets the FFN and why FlashAttention targets memory rather than FLOPs.
def transformer_layer_flops(n, d, ffn_mult=4):
qkv = 3 * 2 * n * d * d
scores= 2 * n * n * d
ctx = 2 * n * n * d
proj = 2 * n * d * d
ffn = 2 * (2 * n * d * ffn_mult * d)
return dict(qkv=qkv, attn=scores + ctx, proj=proj, ffn=ffn,
total=qkv + scores + ctx + proj + ffn)
for n in (512, 4096, 32768):
f = transformer_layer_flops(n, d=4096)
share = 100 * f["attn"] / f["total"]
print(f"n={n:6d} total={f['total']/1e9:8.1f} GFLOPs attention={share:4.1f}%")
# n= 512 total= 111.7 GFLOPs attention= 3.8%
# n= 4096 total= 1168.2 GFLOPs attention=23.5%
# n= 32768 total= 15032.4 GFLOPs attention=71.4%Say these points
- 2·B·d·h FLOPs is the number to quote; the ×2 is multiply + add.
- Arithmetic intensity, not FLOP count, decides the bottleneck.
- Prefill is compute-bound; decode is bandwidth-bound.
- FFN dominates FLOPs until context length exceeds roughly 6× model width.
Avoid these mistakes
- Quoting O(n³) for matmul — that is only for square n×n×n; state the actual shapes.
- Claiming attention always dominates transformer cost. It does not at typical context lengths.
- Ignoring memory traffic entirely, which is the actual bottleneck in serving.
Expect these follow-ups
- Your decode throughput is 10% of theoretical FLOPs. Walk me through diagnosing it.
- How does grouped-query attention change the memory picture?
- Estimate the KV cache size for a 70B model at 8k context, batch 32.
30-second answer
Eigendecomposition factors a square matrix into directions that only get scaled, A = QΛQ⁻¹. SVD generalises this to any m×n matrix, A = UΣVᵀ, always exists, and is numerically stable. PCA is the SVD of the mean-centred data matrix: right singular vectors are principal directions, and squared singular values are proportional to explained variance.
Eigendecomposition: only for square matrices
An eigenvector is a direction the transformation does not rotate — it only stretches it by . This exists only for square matrices, and even then not always with a real, full basis (a rotation matrix in 2D has no real eigenvectors). For symmetric matrices you get the good case: real eigenvalues and an orthonormal , so .
SVD: works on everything
Geometric reading: any linear map is a rotation (), then an axis-aligned scaling (), then another rotation (). Singular values are non-negative and sorted. The link back to eigenvectors:
- holds the eigenvectors of
- holds the eigenvectors of
- of either product
PCA is SVD of centred data
Given a centred data matrix (n samples, d features), the covariance is . Diagonalising gives principal components — but you should never form explicitly. Squaring the data squares the condition number and destroys precision on ill-conditioned features. Instead take the SVD of directly:
import numpy as np
rng = np.random.default_rng(0)
X = rng.normal(size=(500, 6)) @ rng.normal(size=(6, 6)) # correlated features
Xc = X - X.mean(axis=0) # ALWAYS centre first
# Route 1: eigendecomposition of the covariance matrix
C = np.cov(Xc, rowvar=False)
eigval, eigvec = np.linalg.eigh(C)
eigval, eigvec = eigval[::-1], eigvec[:, ::-1]
# Route 2: SVD of the data matrix (what sklearn actually does)
U, S, Vt = np.linalg.svd(Xc, full_matrices=False)
explained = S**2 / (len(Xc) - 1)
print(np.allclose(eigval, explained, atol=1e-8)) # True
print("explained variance ratio:",
np.round(S**2 / (S**2).sum(), 3))
# [0.512 0.246 0.132 0.07 0.031 0.009]
# Project onto the top 2 components
Z = Xc @ Vt[:2].T # equivalently U[:, :2] * S[:2]
print(Z.shape) # (500, 2)Also be ready for the modern angle: truncated / randomised SVD is how you run PCA on a 10M×1000 matrix, and low-rank factorisation via SVD is the conceptual ancestor of LoRA — you approximate a big weight update with where and are thin.
Say these points
- Eigendecomposition needs a square matrix; SVD always exists.
- V = eigenvectors of AᵀA, U = eigenvectors of AAᵀ, σ = √λ.
- PCA = SVD of centred X; explained variance ∝ σ².
- Never build the covariance matrix explicitly — it squares the condition number.
- Truncated SVD scales PCA; low-rank factorisation underpins LoRA.
Avoid these mistakes
- Saying PCA 'selects features'. It builds linear combinations — interpretability is lost.
- Applying PCA before a train/test split (leakage: the fit sees test statistics).
- Assuming top components are the predictive ones. High variance ≠ high signal.
Expect these follow-ups
- When would PCA hurt a downstream classifier?
- How do you choose k? Defend your answer beyond 'the elbow'.
- Contrast PCA with t-SNE/UMAP for visualising embeddings.
30-second answer
Norms measure size. L2 penalises large weights smoothly and shrinks them proportionally; L1 has a constant-magnitude gradient that drives weights exactly to zero, giving sparsity. Frobenius is the L2 norm of a flattened matrix. A matrix is ill-conditioned when its largest and smallest singular values differ by orders of magnitude — the loss surface becomes a narrow ravine and plain gradient descent zig-zags.
| Norm | Definition | ML use |
|---|---|---|
| L1 | Lasso, sparsity, robust losses, feature selection | |
| L2 | Ridge/weight decay, gradient clipping, embedding normalisation | |
| L∞ | Adversarial robustness budgets (FGSM/PGD) | |
| Frobenius | Weight-matrix regularisation, LoRA reconstruction error | |
| Spectral | Lipschitz control, spectral normalisation in GANs |
Why L1 gives sparsity and L2 does not
Look at the gradients. For L2 the penalty gradient is — it shrinks proportionally, so as the pull also and the weight asymptotes but never arrives. For L1 the subgradient is — constant magnitude regardless of how small $w$ is. Once the data gradient is smaller than , the weight is pushed to exactly zero and pinned there.
The geometric version, if the interviewer prefers pictures: the L1 constraint region is a diamond with corners on the axes; the loss contours are ellipses; an ellipse first touches a diamond at a corner, and corners have zero coordinates. The L2 region is a smooth ball with no corners.
import numpy as np
from sklearn.linear_model import Lasso, Ridge
from sklearn.datasets import make_regression
X, y = make_regression(n_samples=300, n_features=30, n_informative=5,
noise=8.0, random_state=0)
lasso = Lasso(alpha=1.0).fit(X, y)
ridge = Ridge(alpha=1.0).fit(X, y)
print("Lasso exact zeros:", int((lasso.coef_ == 0).sum()), "/ 30")
print("Ridge exact zeros:", int((ridge.coef_ == 0).sum()), "/ 30")
print("Ridge smallest |coef|:", round(float(np.abs(ridge.coef_).min()), 4))
# Lasso exact zeros: 24 / 30
# Ridge exact zeros: 0 / 30
# Ridge smallest |coef|: 0.0431 <- shrunk, never zeroConditioning and why it wrecks training
A large condition number means the loss surface is a long, thin valley. Gradient descent takes a huge step along the steep axis and a tiny one along the flat axis, so it bounces across the valley walls instead of walking down it. The convergence rate of gradient descent on a quadratic degrades like — at that is effectively no progress.
Say these points
- L1 gradient is constant-magnitude → exact zeros → sparsity/feature selection.
- L2 gradient is proportional → smooth shrinkage, never exact zero.
- Frobenius = L2 over a flattened matrix; spectral norm controls Lipschitz constant.
- κ = σ_max/σ_min; large κ → ravine-shaped loss → slow, zig-zagging descent.
- Standardisation, BatchNorm/LayerNorm and Adam are all conditioning improvements.
Avoid these mistakes
- Saying 'L1 is sparse' without the gradient or geometric reason.
- Confusing weight decay with L2 regularisation under Adam — they differ (that is why AdamW exists).
- Regularising bias terms, which usually just underfits the intercept.
Expect these follow-ups
- Why does AdamW decouple weight decay, and what breaks with plain Adam + L2?
- When would you use elastic net over pure Lasso?
- How does gradient clipping by global norm differ from clipping by value?
30-second answer
With upstream gradient g = ∂L/∂y of shape (B×h): ∂L/∂W = xᵀg (d×h), ∂L/∂x = gWᵀ (B×d), and ∂L/∂b = g summed over the batch dimension (h,). The reliable trick is that gradients must match the shape of the thing they differentiate — that determines the transposes uniquely.
Set the shapes down first; they do most of the work.
| Tensor | Shape |
|---|---|
| x (input activations) | B × d |
| W (weights) | d × h |
| b (bias) | h |
| y = xW + b | B × h |
| g = ∂L/∂y (upstream) | B × h |
Scalar derivation, then lift to matrices
Element-wise, . So , and applying the chain rule while summing over every path through the batch:
import torch
B, d, h = 4, 5, 3
x = torch.randn(B, d, requires_grad=True)
W = torch.randn(d, h, requires_grad=True)
b = torch.randn(h, requires_grad=True)
y = x @ W + b
L = y.pow(2).sum() # any scalar loss
L.backward()
# Manual: dL/dy for L = sum(y^2) is 2y
g = 2 * y.detach()
dW_manual = x.detach().T @ g # (d, h)
dx_manual = g @ W.detach().T # (B, d)
db_manual = g.sum(dim=0) # (h,)
print(torch.allclose(dW_manual, W.grad, atol=1e-5)) # True
print(torch.allclose(dx_manual, x.grad, atol=1e-5)) # True
print(torch.allclose(db_manual, b.grad, atol=1e-5)) # TrueWhy the interviewer asks this
Three follow-on facts fall straight out of these formulas and are what they are really probing:
Activation memory
Computing ∂L/∂W needsx. That is why the forward activations must be kept alive until the backward pass, why activation memory scales with batch × sequence × width, and how gradient checkpointing trades recompute for memory.Vanishing/exploding gradients
∂L/∂x = gWᵀ means the gradient is repeatedly multiplied by weight matrices going backwards. If the spectral norms are consistently < 1 it vanishes; > 1 it explodes. Residual connections add an identity path so the product never fully decays.Bias gradients sum, weights do not average
∂L/∂b sums over the batch. Whether your effective learning rate scales with batch size depends on whether your loss usessumormean— a classic source of 'it worked at batch 32 but diverged at batch 512'.
Say these points
- ∂L/∂W = xᵀg, ∂L/∂x = gWᵀ, ∂L/∂b = sum of g over batch.
- Shape matching uniquely determines the transposes.
- Needing x in the backward pass is exactly why activations dominate training memory.
- Repeated multiplication by Wᵀ is the mechanism behind vanishing/exploding gradients.
Avoid these mistakes
- Dropping the batch sum in ∂L/∂b.
- Mixing row-major (xW) and column-major (Wx) conventions mid-derivation.
- Not stating that a loss must be scalar for the gradient to be defined without a seed vector.
Expect these follow-ups
- Now derive the gradient through softmax + cross-entropy and show why the combination simplifies to (p − y).
- How does gradient checkpointing change the memory/compute trade-off numerically?
- Where does the Jacobian-vector vs vector-Jacobian product distinction matter?
Showing 5 of 5 questions for linear-algebra-for-machine-learning.
Check your understanding
5 questions · no sign-up, nothing stored
Hands-on challenge
Build it — this is what you talk about in a deep-dive round.
Implement PCA from scratch and profile a matmul
Write a small NumPy module that reproduces scikit-learn's PCA and empirically confirms the FLOP model from question 2.
Requirements
- `fit(X, k)` that centres the data, runs `np.linalg.svd`, and stores components plus explained-variance ratio.
- `transform(X)` and `inverse_transform(Z)`; verify reconstruction error decreases monotonically with k.
- Assert your explained-variance ratios match `sklearn.decomposition.PCA` to 1e-8.
- Time `A @ B` for square sizes 256…4096 and plot achieved GFLOP/s using the 2·n³ model.
Stretch goals
- Add randomised SVD (Halko et al.) and compare accuracy vs. wall-clock on a 20000×500 matrix.
- Show empirically that centring before PCA changes the first component when the mean is far from the origin.