ML Interview Preparation

ML coding interviews

The ML coding round asks you to build k-means, attention or backprop from raw NumPy — here is each one, runnable, with the traps interviewers watch for.

Read these first

On this page 5
  1. Why this round exists
  2. What the interviewer is watching
  3. A real example you have seen
  4. Remember this
  5. What to learn next

One lesson, three depths. Pick the one that fits you today — you can switch any time.

Beginner — No maths. Plain English.

The ML coding round hands you plain Python and asks you to build a famous algorithm from raw parts.

Imagine interviewing for a cook's job. Nobody asks you to describe biryani. They hand you rice, spices and a pot, and they watch you cook. Ordering from a restaurant is not on the menu today.

That is this round. In daily work you write one line — a library call — and a full algorithm runs for you. The interviewer takes the library away and asks for the algorithm itself.

Why this round exists

Libraries hide everything. A person can ship models for a year by calling library functions, without knowing what any of them do inside.

Usually that is fine. It stops being fine the day the model misbehaves, because debugging needs the understanding the library was hiding. This round checks you have it.

There are two flavours, and recruiters will tell you which you are getting if you ask:

 flavour 1: classic DSA          flavour 2: ML from scratch
 ─────────────────────           ──────────────────────────
 "merge two sorted lists"        "implement k-means"
 "find the duplicate"            "write attention"
                                 "backprop for a tiny net"
 practise: /leetcode/            practise: this lesson

DSA means data structures and algorithms — the classic coding puzzles about arrays, hash maps and trees.

What the interviewer is watching

Not typing speed. They watch whether you talk before you type, whether you test your code on a tiny example, and whether you know why each step exists.

A candidate who writes slower but narrates — "first every point finds its nearest centre, then every centre moves to the middle of its points" — beats a silent fast typist every time.

A real example you have seen

You have already met these algorithms in daily life. K-means is how apps group similar songs into auto-playlists. Attention is the core move inside ChatGPT. Backprop is how every neural network learns, including the one unlocking your phone with your face.

The round asks you to rebuild a small, honest version of machinery you use every day.

Remember this

  • The round tests understanding, not memory. Rebuild each algorithm twice and the understanding sticks.
  • Narrate your plan before code, and test on tiny data after. Both are graded.
  • Two flavours: DSA (practise on /leetcode/) and from scratch (practise below).

What to learn next

Developer — Code and libraries.

Setup

bash
pip install numpy

Everything below runs on a plain CPU in under two seconds, with no downloads. The three algorithms here — k-means, attention, backprop — are the three most commonly asked, and each one carries a trap that interviewers deliberately probe.

For the DSA flavour of this round, this site's /leetcode/ archive has 1,055 worked solutions, filterable by topic. The rest of this lesson is the from-scratch flavour.

1. K-means, with the trap included

The task as asked: "Implement k-means. You may use NumPy." Expected in roughly 20 minutes.

K-means repeats two steps: assign every point to its nearest centroid, then move each centroid to the mean of its points. The trap is what nobody mentions: where the centroids start.

kmeans_scratch.py
import numpy as np

rng = np.random.default_rng(0)

# 30 points in 2D, drawn around three separated centres
data = np.vstack([
    rng.normal([0, 0], 0.5, (10, 2)),
    rng.normal([5, 5], 0.5, (10, 2)),
    rng.normal([0, 5], 0.5, (10, 2)),
])


def kmeans(data, k, seed, steps=20):
    rng = np.random.default_rng(seed)
    centroids = data[rng.choice(len(data), k, replace=False)]  # start on real points
    for _ in range(steps):
        # distance from every point to every centroid, no Python loops
        d = np.linalg.norm(data[:, None, :] - centroids[None, :, :], axis=2)
        labels = d.argmin(axis=1)                  # assign: nearest centroid wins
        new = np.array([data[labels == j].mean(axis=0) for j in range(k)])
        if np.allclose(new, centroids):            # centroids stopped moving
            break
        centroids = new
    inertia = (d.min(axis=1) ** 2).sum()           # the number k-means shrinks
    return centroids, inertia


results = [kmeans(data, k=3, seed=s) for s in range(5)]
for s, (_, inertia) in enumerate(results):
    print(f"seed {s}  final inertia {inertia:8.3f}")

best, _ = min(results, key=lambda r: r[1])
print("best centroids found:")
print(np.round(best[np.argsort(best[:, 0])], 2))   # sorted so the order is stable
Output
seed 0  final inertia  148.568
seed 1  final inertia  148.568
seed 2  final inertia  124.485
seed 3  final inertia  124.415
seed 4  final inertia   10.772
best centroids found:
[[-0.17 -0.01]
 [ 0.1   5.26]
 [ 4.9   5.16]]

Read that output slowly, because it is the whole interview. Five random starts on the same easy data gave three different answers, and four of the five were bad. Only seed 4 found the true clusters, with inertia dropping from 148 to 10.8.

Inertia is the sum of squared distances from each point to its assigned centroid — the quantity k-means minimises. Each run only ever lowers it, but runs get stuck in local minima: arrangements no single step can improve, that are still wrong. Two centroids land in one blob and split it, while one centroid stretches across two blobs.

This is the follow-up question, pre-answered: "How do you make k-means reliable?" Run it several times and keep the lowest inertia, or start smarter. Smarter starting is k-means++, which picks initial centroids spread far apart — it is what libraries do by default. The concepts live in the clustering lesson.

The other line worth narrating is the distance computation. data[:, None, :] - centroids[None, :, :] uses broadcasting — NumPy stretching shapes (30, 1, 2) and (1, 3, 2) into (30, 3, 2) — to get every point-to-centroid distance without a Python loop. Say the shapes out loud as you write them. Interviewers grade that.

2. Scaled dot-product attention

The task as asked: "Write the attention function: given Q, K, V, return the output. Then add a causal mask." Expected in roughly 15 minutes.

Attention lets each token in a sequence build its output as a weighted mix of every token's information, with the weights computed from relevance. If the idea itself is new to you, read attention first — this is the implementation.

attention_scratch.py
import numpy as np

rng = np.random.default_rng(0)

seq_len, d_k = 4, 8                        # four tokens, eight dimensions each
Q = rng.normal(size=(seq_len, d_k))        # what each token is looking for
K = rng.normal(size=(seq_len, d_k))        # what each token offers to be found by
V = rng.normal(size=(seq_len, d_k))        # what each token hands over if chosen


def softmax(x):
    x = x - x.max(axis=-1, keepdims=True)  # subtract the max: overflow-proof, same answer
    e = np.exp(x)
    return e / e.sum(axis=-1, keepdims=True)


scores = Q @ K.T / np.sqrt(d_k)            # every token scored against every token
weights = softmax(scores)                  # each row becomes a probability distribution
out = weights @ V                          # each output row is a weighted mix of V rows

print("attention weights, one row per token:")
print(np.round(weights, 3))
print("row sums:", np.round(weights.sum(axis=1), 3))
print("output shape:", out.shape)

# the causal mask a decoder adds: a token may not look at tokens after itself
mask = np.triu(np.ones((seq_len, seq_len)), k=1).astype(bool)
masked = softmax(np.where(mask, -1e9, scores))
print("with a causal mask (upper triangle forced to zero):")
print(np.round(masked, 3))
Output
attention weights, one row per token:
[[0.302 0.472 0.081 0.145]
 [0.329 0.069 0.247 0.354]
 [0.32  0.388 0.233 0.059]
 [0.102 0.031 0.684 0.183]]
row sums: [1. 1. 1. 1.]
output shape: (4, 8)
with a causal mask (upper triangle forced to zero):
[[1.    0.    0.    0.   ]
 [0.827 0.173 0.    0.   ]
 [0.34  0.413 0.247 0.   ]
 [0.102 0.031 0.684 0.183]]

Three graded details hide in eleven lines of core code.

The max subtraction in softmax. np.exp(1000) overflows to infinity and the weights become nan. Subtracting the row maximum changes nothing mathematically — softmax only cares about differences — and makes overflow impossible. Interviewers feed large scores on purpose to see if your softmax survives.

The scaling by the square root of the dimension. With larger d_k, dot products grow, softmax saturates towards one-hot, and gradients die. Dividing by the square root of d_k keeps score variance near 1. Saying that sentence is the difference between "memorised the formula" and "understands it".

The mask uses a large negative number, not zero. Zeroing scores does not remove a token: a score of 0 still earns weight after softmax. Setting masked scores to a huge negative number drives their weight to zero. Check the first row of the masked output — the first token can only attend to itself, so its row reads 1.0 and zeros.

3. Backprop on a tiny network

The task as asked: "Train a small network on XOR without any framework. Write the gradients by hand." Expected in roughly 25 minutes — this is the hardest of the three.

XOR is the classic because a single layer cannot solve it — no straight line separates the classes — so your hidden layer and its gradients must genuinely work. If the chain-rule story is fuzzy, backpropagation teaches it fully; this is the interview version.

backprop_scratch.py
import numpy as np

rng = np.random.default_rng(42)

X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]], dtype=float)
y = np.array([[0], [1], [1], [0]], dtype=float)    # XOR: no straight line separates it

W1 = rng.normal(0, 1, (2, 4)); b1 = np.zeros(4)
W2 = rng.normal(0, 1, (4, 1)); b2 = np.zeros(1)
lr = 1.0


def sigmoid(z):
    return 1 / (1 + np.exp(-z))


for step in range(3001):
    # forward pass, keeping every intermediate - backprop will need them
    h = np.tanh(X @ W1 + b1)
    p = sigmoid(h @ W2 + b2)
    loss = -(y * np.log(p) + (1 - y) * np.log(1 - p)).mean()

    # backward pass: chain rule, one layer at a time, top to bottom
    dz2 = (p - y) / len(X)          # sigmoid + cross-entropy collapses to this
    dW2 = h.T @ dz2; db2 = dz2.sum(0)
    dh = dz2 @ W2.T                 # send the gradient through W2 to the hidden layer
    dz1 = dh * (1 - h ** 2)         # tanh'(z) = 1 - tanh(z)^2, and h is tanh(z)
    dW1 = X.T @ dz1; db1 = dz1.sum(0)

    for param, grad in ((W1, dW1), (b1, db1), (W2, dW2), (b2, db2)):
        param -= lr * grad
    if step % 1000 == 0:
        print(f"step {step:4d}  loss {loss:.4f}")

print("predictions for [00, 01, 10, 11]:", np.round(p.ravel(), 3))
Output
step    0  loss 0.8299
step 1000  loss 0.0024
step 2000  loss 0.0011
step 3000  loss 0.0007
predictions for [00, 01, 10, 11]: [0.    0.999 0.999 0.001]

The loss falls three orders of magnitude and the four predictions land on XOR's truth table. Two lines carry almost all the marks.

dz2 = (p - y) / len(X) — the gradient of cross-entropy loss through a sigmoid collapses to prediction minus target. Deriving or at least stating this collapse is a standard follow-up. The division by len(X) matches the .mean() in the loss; forget it and your gradient is four times too large.

dz1 = dh * (1 - h ** 2) — the chain rule passing through tanh. The elegance is that h was already computed in the forward pass, so the derivative costs almost nothing. That reuse of forward intermediates is backpropagation.

If your interview allows a shape check, one sanity rule catches most bugs: every gradient has the same shape as its parameter. dW1.shape == W1.shape, always. Check it before running.

Common mistakes across all three

Starting to type immediately. State the algorithm in two sentences first. It costs 20 seconds and buys you a plan, plus visible marks.

No tiny test. Run k-means on ten points, attention on four tokens, the network on XOR. A candidate who invents a three-line test case reads as someone who has debugged real models.

Looping where broadcasting works. Triple-nested Python loops for distances are correct and score poorly. Knowing the vectorised form is part of what the round measures.

Dividing by zero on an empty cluster. In k-means, a centroid can end up with no points, and mean of nothing is nan. Mentioning the case — "I would reseed an empty centroid to a random point" — earns more than silently hoping.

Forgetting numerical stability. The unstable softmax is the single most common failure in the attention question. Write the max subtraction by reflex.

Try it yourself

Rebuild each of the three from a blank file, without looking, one per day for three days. Then do the standard variations: k-means with the empty-cluster fix, attention with two heads (split d_k in half, run twice, concatenate), and the XOR network with ReLU hidden units instead of tanh — which changes exactly one forward line and one backward line.

What to learn next

Researcher — Mathematics and papers.

What the rubric actually contains

From-scratch rounds are typically scored on four axes: correctness on a small case, complexity awareness, numerical care, and communication. The bar for "strong" usually requires unprompted statements of cost and failure modes. This section is those statements.

K-means, precisely

K-means minimises within-cluster sum of squares:

$$ \underset{C_1,\dots,C_k}{\arg\min} \; \sum_{j=1}^{k} \sum_{x \in C_j} \lVert x - \mu_j \rVert^2 $$

Where:

  • $C_j$ — the set of points assigned to cluster $j$.
  • $\mu_j$ — the centroid (mean) of cluster $j$.
  • $k$ — the number of clusters, fixed in advance.
  • $\lVert \cdot \rVert$ — Euclidean norm.

Exact minimisation is NP-hard in general dimension even for $k = 2$ (Aloise et al., 2009). What everyone implements is Lloyd's algorithm (Lloyd, 1957/1982): alternate assignments and mean updates. Each iteration costs $O(nkd)$ for $n$ points in $d$ dimensions, and each iteration weakly decreases the objective, so it converges — to a local optimum with no global guarantee, as the five-seed output in the developer tab demonstrates empirically.

k-means++ (Arthur and Vassilvitskii, 2007) samples each next initial centroid with probability proportional to $D(x)^2$, the squared distance to the nearest centroid chosen so far. This yields $\mathbb{E}[\text{cost}] \le 8(\ln k + 2) \cdot \text{OPT}$ — an $O(\log k)$-competitive guarantee before Lloyd's algorithm even starts. Citing that guarantee, not the recipe, is the researcher-level answer.

Attention, precisely

$$ \text{Attention}(Q, K, V) = \text{softmax}!\left(\frac{QK^\top}{\sqrt{d_k}}\right) V $$

Where $Q \in \mathbb{R}^{n \times d_k}$, $K \in \mathbb{R}^{n \times d_k}$, $V \in \mathbb{R}^{n \times d_v}$ for sequence length $n$; queries, keys and values are learned projections of the token representations (Vaswani et al., 2017).

The scaling has a variance argument behind it. If the components of $q$ and $k$ are independent with mean 0 and variance 1, then $q \cdot k = \sum_{i=1}^{d_k} q_i k_i$ has mean 0 and variance $d_k$. Dividing by $\sqrt{d_k}$ restores unit variance, keeping the softmax out of its saturated region where gradients vanish.

Cost is the follow-up: the score matrix is $n \times n$, so time and memory are $O(n^2 d)$ — the reason long-context models need attention variants, and a good moment to mention FlashAttention (Dao et al., 2022), which computes exact attention without materialising the $n \times n$ matrix in slow memory.

The numerically stable softmax relies on shift invariance: $\text{softmax}(x) = \text{softmax}(x - c)$ for any constant $c$, because $e^{x_i - c}$ factors out $e^{-c}$ from numerator and denominator. Choosing $c = \max_i x_i$ bounds every exponent at 0.

Backprop, precisely

Backpropagation is reverse-mode automatic differentiation applied to the computation graph of a scalar loss. For a composition $L = f_L \circ \dots \circ f_1$, reverse mode computes vector-Jacobian products from the loss downward, costing a small constant multiple of the forward evaluation regardless of parameter count (Griewank and Walther, 2008). Forward-mode would cost one pass per parameter — hopeless at a million parameters, which is why reverse mode won.

The collapse used in the developer code: with sigmoid output $p = \sigma(z)$ and cross-entropy $L = -[y \log p + (1-y)\log(1-p)]$,

$$ \frac{\partial L}{\partial z} = p - y $$

because $\sigma'(z) = p(1-p)$ cancels the $p(1-p)$ in the denominator of $\partial L / \partial p \cdot \partial p / \partial z$. The identical collapse holds for softmax with multi-class cross-entropy. It is numerically kinder too: the fused form never divides by a probability that may be near zero.

The memory statement worth making unprompted: training stores forward activations for reuse in the backward pass, so activation memory scales with batch size times network width times depth — usually the binding constraint, not parameter memory. Gradient checkpointing trades recomputation for that memory.

Verification under interview conditions

The professional's trick for checking hand-written gradients is the finite-difference check: for a few random parameters $\theta_i$, compare the analytic gradient against $[L(\theta_i + \epsilon) - L(\theta_i - \epsilon)] / 2\epsilon$ with $\epsilon \approx 10^{-5}$, expecting relative error below about $10^{-6}$ in float64. Offering this check unasked signals experience debugging real training code.

Sources

  • Lloyd, S. (1982), Least squares quantization in PCM, IEEE Trans. Information Theory — written 1957, published later.
  • Arthur, D. and Vassilvitskii, S. (2007), k-means++: the advantages of careful seeding, SODA.
  • Aloise, D. et al. (2009), NP-hardness of Euclidean sum-of-squares clustering, Machine Learning 75.
  • Vaswani, A. et al. (2017), Attention is all you need — arxiv.org/abs/1706.03762
  • Dao, T. et al. (2022), FlashAttention — arxiv.org/abs/2205.14135
  • Rumelhart, D., Hinton, G. and Williams, R. (1986), Learning representations by back-propagating errors, Nature 323.
  • Griewank, A. and Walther, A. (2008), Evaluating Derivatives, 2nd ed., SIAM.

What to learn next