Type at least 2 letters. Enter opens the first result, Esc closes.

    ML Interview Prep · Lesson 6 · ML coding from scratch

    ML coding in NumPy: softmax, logistic regression, k-means, AUC, MLP backprop

    Intermediate 75 min 4 interactive figures 4 videos Colab notebook

    Before this lesson: the chain rule and backpropagation (Calculus 06, Deep learning 03); gradients of matrix expressions (Linear algebra 16); logistic regression, k-means and ROC curves at the level of a first ML course (for example, the ML fundamentals part of a university course). NumPy broadcasting and the axis= argument. Time: about 75 minutes, plus the notebook. Notation: nn examples, dd features, KK classes, BB batch size, hh hidden width; XX has one example per row.


    Why this matters #

    ML engineer and research engineer interviews often include an "ML coding" round: implement softmax and cross-entropy, logistic regression, k-means, a metric such as AUC, or an MLP with backprop, then answer follow-up questions. Attention and a full training loop come next (lesson 07, lesson 08). Interviewers grade correct math, vectorized code (no Python loop over examples), numerical stability, a check that the code works, and how clearly you explain while you code.

    The same details matter in real work. The LLM pretraining loss is a softmax cross-entropy over about 10510^5 tokens, computed with log-sum-exp. Attention uses the same max shift. AUC is the standard score of a detector of rare events. A custom layer or a physics-informed loss needs a hand-written gradient that you check with finite differences.

    1. Predict first #

    2. The picture #

    One routine for every algorithm (times for a 45-minute round):

    1. Clarify (3 min). Shapes and dtypes; label coding ({0,1}\{0, 1\} or {−1,+1}\{-1, +1\}); mean or sum loss; regularization (never on the bias); what to return. Write the signature with shape comments.
    2. Math first (5 min). Write each formula in matrix form as a comment, one line each.
    3. Stable form. Mark every exp and log. Use the max shift, log-sum-exp or softplus.
    4. Vectorize. One matrix product per layer or per distance table. Loop over iterations, never over examples.
    5. Test (5–10 min). A tiny case by hand; a library reference (SciPy, scikit-learn); a property: probabilities sum to 1, the loss decreases, a finite-difference gradient check passes.
    6. Talk. Cost in time and memory, and the follow-ups you expect.

    Step 1 in practice. The signature and the docstring already prevent most bugs:

    def fit_logreg(X, y, lam=0.0, lr=0.1, steps=1000):
        """X: (n, d) float64, y: (n,) in {0, 1}.  Returns w: (d,), b: float.
        Loss: mean(softplus(z) - y * z) + lam/2 * ||w||^2,  z = X @ w + b  (b not penalized)."""
        ...
    

    The simplest case of each idea.

    • Softmax of two logits is a sigmoid: softmax⁡(z1,z2)2=σ(z2−z1)\operatorname{softmax}(z_1, z_2)_2 = \sigma(z_2 - z_1), with σ(t)=1/(1+e−t)\sigma(t) = 1/(1 + e^{-t}).
    • Logistic regression in one dimension without bias has one weight: p=σ(wx)p = \sigma(wx).
    • k-means on a line with k=2k = 2 splits the points into a left group and a right group.
    • AUC with one positive and one negative is 1 if the positive scores higher, else 0 (and ½ on a tie).
    • An MLP with one hidden unit is a chain of scalar functions, and backprop is the chain rule from the end.

    Why softmax needs care. A logit is an unnormalized score; softmax turns a vector of logits zz into probabilities pk=ezk/∑jezjp_k = e^{z_k}/\sum_j e^{z_j}. In float64 the largest number is about 1.8×103081.8 \times 10^{308}, so eze^z is inf for z>709.78z > 709.78, and eze^z rounds to 0 for z<−745z < -745. Both happen with real logits (for example after a bad initialization, or with large attention scores).

    Interactive figure: loading…

    Move the logits, or pick an example. The left half of the table is the naive computation in float64; the right half first subtracts m=max⁡jzjm = \max_j z_j. With large logits the naive form gives inf/inf = nan; with very negative logits it gives 0/0 = nan. The shifted form never fails. Try (0,−1000,−5)(0, -1000, -5) with class 2: the naive cross-entropy is inf, but LSE(z)−zy\mathrm{LSE}(z) - z_y gives the right value, about 1000.

    Quick check

    In float64, what does the naive np.exp(z) / np.exp(z).sum() return for z=(−1000,−1001,−999)z = (-1000, -1001, -999)?

    3. Definitions and main results #

    3.1 Stable softmax, log-softmax and cross-entropy #

    For logits z∈RKz \in \mathbb{R}^K: softmax⁡(z)k=ezk/∑j=1Kezj\operatorname{softmax}(z)_k = e^{z_k} / \sum_{j=1}^K e^{z_j}.

    Shift invariance. softmax⁡(z−c1)=softmax⁡(z)\operatorname{softmax}(z - c\mathbf{1}) = \operatorname{softmax}(z) for every number cc.

    Proof. ezk−c/∑jezj−c=e−cezk/(e−c∑jezj)e^{z_k - c} / \sum_j e^{z_j - c} = e^{-c} e^{z_k} / (e^{-c} \sum_j e^{z_j}), and e−ce^{-c} cancels. ■\blacksquare

    Take c=m=max⁡jzjc = m = \max_j z_j. Then every exponent is ≤0\le 0, so nothing overflows. The largest term is e0=1e^0 = 1, so the sum lies in [1,K][1, K]: no division by 0, and its log⁡\log is ≥0\ge 0.

    Log-sum-exp. LSE⁡(z)=log⁡∑jezj=m+log⁡∑jezj−m\operatorname{LSE}(z) = \log \sum_j e^{z_j} = m + \log \sum_j e^{z_j - m}. Then log⁡softmax⁡(z)k=zk−LSE⁡(z)\log \operatorname{softmax}(z)_k = z_k - \operatorname{LSE}(z).

    Cross-entropy from logits. For the true class yy, the cross-entropy loss is ℓ=−log⁡py=LSE⁡(z)−zy\ell = -\log p_y = \operatorname{LSE}(z) - z_y. Never compute −log⁡(softmax⁡(z)y)-\log(\operatorname{softmax}(z)_y): a tiny pyp_y rounds to 0, and the loss becomes ∞\infty. Example: z=(0,−1000)z = (0, -1000) with y=2y = 2 has ℓ=1000\ell = 1000, but −log⁡-\log of the rounded probability gives inf.

    Gradient. ∂ℓ/∂z=p−ey\partial \ell / \partial z = p - e_y, where eye_y is the one-hot vector of class yy.

    Proof. ∂LSE⁡/∂zk=ezk/∑jezj=pk\partial \operatorname{LSE}/\partial z_k = e^{z_k}/\sum_j e^{z_j} = p_k, and ∂zy/∂zk=1\partial z_y / \partial z_k = 1 if k=yk = y, else 0. ■\blacksquare

    For a mean over a batch of BB examples, divide by BB. The entries of p−eyp - e_y sum to 0.

    Binary case. Compute the sigmoid σ(t)\sigma(t) as 1/(1+e−t)1/(1 + e^{-t}) for t≥0t \ge 0 and as et/(1+et)e^{t}/(1 + e^{t}) for t<0t < 0, so exp never sees a large positive argument. The binary cross-entropy of a logit zz with label y∈{0,1}y \in \{0, 1\} is ℓ=softplus⁡(z)−yz\ell = \operatorname{softplus}(z) - yz, where the softplus is softplus⁡(z)=log⁡(1+ez)=max⁡(z,0)+log⁡(1+e−∣z∣)\operatorname{softplus}(z) = \log(1 + e^z) = \max(z, 0) + \log(1 + e^{-|z|}) (np.logaddexp(0, z) computes it).

    Why softplus⁡(z)−yz\operatorname{softplus}(z) - yz: −log⁡σ(z)=log⁡(1+e−z)=softplus⁡(z)−z-\log \sigma(z) = \log(1 + e^{-z}) = \operatorname{softplus}(z) - z and −log⁡(1−σ(z))=log⁡(1+ez)=softplus⁡(z)-\log(1 - \sigma(z)) = \log(1 + e^{z}) = \operatorname{softplus}(z). Mix the two with yy and 1−y1 - y.

    3.2 Logistic regression #

    Data X∈Rn×dX \in \mathbb{R}^{n \times d}, labels y∈{0,1}ny \in \{0, 1\}^n, weights w∈Rdw \in \mathbb{R}^d, bias bb. Logits z=Xw+bz = Xw + b, probabilities p=σ(z)p = \sigma(z) (elementwise). The loss with an L2 penalty λ≥0\lambda \ge 0 is

    L(w,b)=1n∑i=1n[softplus⁡(zi)−yizi]+λ2∥w∥2.L(w, b) = \frac{1}{n}\sum_{i=1}^n \big[\operatorname{softplus}(z_i) - y_i z_i\big] + \frac{\lambda}{2}\|w\|^2 .

    Gradient, step by step.

    1. ddzsoftplus⁡(z)=ez1+ez=σ(z)\frac{d}{dz}\operatorname{softplus}(z) = \frac{e^z}{1 + e^z} = \sigma(z), so ∂ℓi/∂zi=pi−yi\partial \ell_i / \partial z_i = p_i - y_i.
    2. ∂zi/∂w=xi\partial z_i / \partial w = x_i (row ii of XX) and ∂zi/∂b=1\partial z_i / \partial b = 1.
    3. Average over ii and add the penalty:
    ∇wL=1nX⊤(p−y)+λw,\nabla_w L = \frac{1}{n} X^\top (p - y) + \lambda w,
    ∂L∂b=1n∑i(pi−yi).\frac{\partial L}{\partial b} = \frac{1}{n}\sum_i (p_i - y_i).

    The shapes check it: X⊤X^\top is d×nd \times n and p−yp - y is n×1n \times 1, so the product is d×1d \times 1, the shape of ww.

    Convexity and the step size. The Hessian in ww is 1nX⊤SX+λI\frac{1}{n} X^\top S X + \lambda I with S=diag⁡(pi(1−pi))S = \operatorname{diag}(p_i(1 - p_i)). It is positive semidefinite, so LL is convex. Since p(1−p)≤1/4p(1 - p) \le 1/4, the gradient is Lipschitz with a constant (the smoothness constant) β≤λmax⁡(X⊤X)/(4n)+λ\beta \le \lambda_{\max}(X^\top X)/(4n) + \lambda. (To include bb, append a column of ones to XX.) Gradient descent with a step size η≤1/β\eta \le 1/\beta never increases the loss (Optimization 02). Standardize the features first: it makes β\beta smaller and the problem better conditioned.

    Separable data. The classes are linearly separable if some line (hyperplane) puts all positives on one side and all negatives on the other. Then, with λ=0\lambda = 0, the loss can come as close to 0 as you like but never reaches it: there is no minimizer. Gradient descent makes ∥w∥\|w\| grow without bound (about like log⁡t\log t), and the direction of ww tends to the max-margin direction (Soudry et al., 2018). Use λ>0\lambda > 0 or stop early.

    Interactive figure: loading…

    Press +10, +100 and +1000 to run gradient descent. The violet line is the decision line w⋅x+b=0w \cdot x + b = 0; the dashed lines are where p=0.9p = 0.9 (blue) and p=0.1p = 0.1 (orange). On separable data with λ=0\lambda = 0 the loss keeps falling and ∥w∥\|w\| keeps growing, so the dashed lines close in on the decision line. Set λ=0.01\lambda = 0.01 and run again: ∥w∥\|w\| stops growing. Switch to overlapping data: now a finite minimizer exists even with λ=0\lambda = 0.

    Quick check

    X has shape (n, d), and p, y have shape (n,). Which line is the gradient of the mean logistic loss with the penalty λ2∥w∥2\frac{\lambda}{2}\|w\|^2?

    3.3 k-means #

    Points x1,…,xn∈Rdx_1, \dots, x_n \in \mathbb{R}^d, kk centers μ1,…,μk\mu_1, \dots, \mu_k, and an assignment ci∈{1,…,k}c_i \in \{1, \dots, k\} for each point. The objective, called the inertia, is

    J(c,μ)=∑i=1n∥xi−μci∥2.J(c, \mu) = \sum_{i=1}^n \|x_i - \mu_{c_i}\|^2 .

    Lloyd's algorithm alternates two steps until the assignment stops changing:

    • (a) assign: ci=arg⁡min⁡j∥xi−μj∥2c_i = \arg\min_j \|x_i - \mu_j\|^2 (on a tie, keep the current center);
    • (b) update: set each μj\mu_j to the mean of the points with ci=jc_i = j.

    Theorem. JJ never increases, and Lloyd's algorithm stops after a finite number of steps.

    Proof.

    1. Step (a) chooses the best center for each point with μ\mu fixed, so JJ does not increase. If some cic_i changes, that point moved to a strictly closer center (a tie keeps the old one), so JJ strictly decreases.
    2. Step (b) minimizes JJ over μ\mu with cc fixed. The cost of cluster jj, ∑i:ci=j∥xi−μj∥2\sum_{i: c_i = j}\|x_i - \mu_j\|^2, is a convex quadratic in μj\mu_j. Its gradient 2∑i:ci=j(μj−xi)2\sum_{i: c_i = j}(\mu_j - x_i) is zero at the mean. The old center is one of the candidates, so JJ does not increase.
    3. After step (b), JJ depends only on the partition (the centers are its means). By steps 1–2, JJ strictly decreases every time the partition changes, so no partition appears twice.
    4. There are at most knk^n partitions, so the algorithm stops. ■\blacksquare

    It stops at a local optimum, not always the global one (finding the global optimum is NP-hard in general). So run it from several starts (n_init in scikit-learn) and keep the lowest JJ.

    Vectorized distances. Dij=∥xi−μj∥2=∥xi∥2−2xi⊤μj+∥μj∥2D_{ij} = \|x_i - \mu_j\|^2 = \|x_i\|^2 - 2x_i^\top\mu_j + \|\mu_j\|^2. With the centers as the rows of MM (k×dk \times d), this needs one matrix product XM⊤XM^\top, of shape (n,d)(d,k)→(n,k)(n, d)(d, k) \to (n, k), plus two broadcast vectors. Clip DD at 0, because round-off can make tiny values negative. The cost per iteration is O(nkd)O(nkd).

    k-means++ seeding. Pick the first center uniformly from the data. Pick each next center at random with probability proportional to D(x)2D(x)^2, the squared distance from xx to its nearest chosen center. Then the expected JJ is within a factor O(log⁡k)O(\log k) of the optimum (Arthur and Vassilvitskii, 2007).

    Empty cluster. If no point is assigned to center jj, its mean is 0/00/0. Re-seed it, for example at the point that is farthest from its own center.

    Interactive figure: loading…

    Press "Next" to run one half-step at a time, and watch JJ in the chart. Each point takes the colour of its cluster; thin lines join it to its center. JJ goes down or stays the same after every half-step. The bad start puts one center between two blobs and two centers in the third blob: Lloyd's algorithm stops in a poor local optimum. Try k-means++ seeds, or drag a center yourself. The worked example is the line example of section 4.

    Quick check

    What does step (b) of Lloyd's algorithm minimize?

    3.4 ROC AUC #

    Scores sis_i (higher means "more likely positive") and labels yi∈{0,1}y_i \in \{0, 1\}, with n+n_+ positives and n−n_- negatives. The ROC curve plots the true positive rate (the fraction of positives above the threshold) against the false positive rate (the fraction of negatives above the threshold) while the threshold moves down from +∞+\infty. AUC is the area under it.

    Theorem (AUC as a probability). For a random positive with score S+S^+ and a random negative with score S−S^-:

    AUC=P(S+>S−)+12P(S+=S−).\mathrm{AUC} = P(S^+ > S^-) + \tfrac12 P(S^+ = S^-) .

    This is the Mann–Whitney UU statistic divided by n+n−n_+ n_-.

    Why this is true. When the threshold passes one negative, the curve moves right by 1/n−1/n_-. The height at that moment is the fraction of positives that score above this negative. So the area added is 1n−×\frac{1}{n_-} \times (fraction of positives above it). Summed over all negatives, this is the fraction of (positive, negative) pairs in the right order. When a positive and a negative share a score, the curve moves diagonally, and the triangle under that step adds exactly half a pair.

    Rank formula, O(nlog⁡n)O(n \log n). Sort all scores in ascending order and give rank 1 to the smallest; tied scores get the average of their ranks. Let R+R_+ be the sum of the ranks of the positives. Then

    AUC=R+−n+(n++1)/2n+n−.\mathrm{AUC} = \frac{R_+ - n_+(n_+ + 1)/2}{n_+ n_-} .

    Proof (no ties).

    1. A positive with rank rr has r−1r - 1 scores below it: some are negatives, some are positives.
    2. Over all positives, the number of (positive, positive) pairs with one below the other is 0+1+⋯+(n+−1)=n+(n+−1)/20 + 1 + \dots + (n_+ - 1) = n_+(n_+ - 1)/2.
    3. So the number of (positive, negative) pairs with the positive above is the total ∑(r−1)=R+−n+\sum (r - 1) = R_+ - n_+ minus the (positive, positive) pairs: R+−n+−n+(n+−1)/2=R+−n+(n++1)/2R_+ - n_+ - n_+(n_+ - 1)/2 = R_+ - n_+(n_+ + 1)/2.
    4. Divide by the n+n−n_+ n_- pairs. ■\blacksquare

    With average ranks, a tied (positive, negative) pair adds 12\tfrac12, as in the theorem.

    Interactive figure: loading…

    Drag the scores and watch three things agree. The grid has one cell for each (positive, negative) pair: 1 if the positive scores higher, ½ on a tie, 0 otherwise. The shaded area under the ROC curve, the grid sum divided by n+n−n_+ n_-, and the rank formula always give the same number. Move a positive onto a negative to make a tie: the ROC curve gets a diagonal step.

    Properties. AUC does not change under a strictly increasing transform of the scores (only the order matters). AUC(−s)=1−AUC(s)\mathrm{AUC}(-s) = 1 - \mathrm{AUC}(s). A random scorer has AUC 0.5. AUC uses rates within each class, so it does not change when the class ratio changes. Under heavy imbalance it can look good while the precision is poor, so also report the area under the precision–recall curve (PR-AUC, or average precision). With only one class present, AUC is undefined: raise an error. The O(n+n−)O(n_+ n_-) count over all pairs is a good test oracle for your O(nlog⁡n)O(n \log n) version.

    Quick check

    Scores (0.8,0.6,0.6,0.3)(0.8, 0.6, 0.6, 0.3) have the labels (1,1,0,0)(1, 1, 0, 0). What is the AUC?

    3.5 MLP backprop #

    A 2-layer MLP on a batch X∈RB×dX \in \mathbb{R}^{B \times d} with integer labels yy, hidden width hh and KK classes. Y∈{0,1}B×KY \in \{0, 1\}^{B \times K} is the one-hot label matrix, ⊙\odot is the elementwise product, relu⁡(u)=max⁡(u,0)\operatorname{relu}(u) = \max(u, 0), and dWdW means ∂L/∂W\partial L / \partial W.

    Forward Shape Backward Shape
    H=XW1+b1H = XW_1 + b_1 B×hB \times h G=(P−Y)/BG = (P - Y)/B B×KB \times K
    A=relu⁡(H)A = \operatorname{relu}(H) B×hB \times h dW2=A⊤GdW_2 = A^\top G, db2=∑rowsGdb_2 = \sum_{\text{rows}} G h×Kh \times K, KK
    Z=AW2+b2Z = AW_2 + b_2 B×KB \times K dA=GW2⊤dA = G W_2^\top B×hB \times h
    P=softmax⁡(Z)P = \operatorname{softmax}(Z), by rows B×KB \times K dH=dA⊙1[H>0]dH = dA \odot \mathbf{1}[H > 0] B×hB \times h
    L=1B∑i(LSE⁡(Zi)−Zi,yi)L = \frac1B\sum_i \big(\operatorname{LSE}(Z_i) - Z_{i, y_i}\big) scalar dW1=X⊤dHdW_1 = X^\top dH, db1=∑rowsdHdb_1 = \sum_{\text{rows}} dH d×hd \times h, hh

    Read the backward column from the top: it runs from the loss back to the input.

    • G=(P−Y)/BG = (P - Y)/B is the cross-entropy gradient of section 3.1, one row per example, divided by BB because LL is a mean.
    • For Z=AW2+b2Z = AW_2 + b_2: dW2=A⊤GdW_2 = A^\top G and dA=GW2⊤dA = GW_2^\top (Linear algebra 16 derives these rules).
    • ReLU passes the gradient where H>0H > 0 and blocks it elsewhere.

    Two rules catch most bugs: every gradient has the shape of its parameter, and dWdW = (layer input)⊤^\top × (upstream gradient). The bias gradient sums over the batch, because bb is added to every row.

    Gradient check. Compare entries with the central difference (L(θ+εej)−L(θ−εej))/(2ε)\big(L(\theta + \varepsilon e_j) - L(\theta - \varepsilon e_j)\big)/(2\varepsilon), in float64 with ε≈10−6\varepsilon \approx 10^{-6}; eje_j is the unit vector of entry jj. Look at the relative error ∣gnum−g∣/(∣gnum∣+∣g∣)|g_{\text{num}} - g| / (|g_{\text{num}}| + |g|): below about 10−610^{-6} means correct; about 10−210^{-2} or more means a bug. Check a tiny network, or a few random entries of a big one: a full check costs two forward passes per parameter (Calculus 07).

    Quick check

    In the 2-layer MLP, what is dW1dW_1?

    Answers to section 1. (1) [nan, nan]: both exponentials overflow to inf, and inf/inf is nan. The true value is (σ(−1),σ(1))≈(0.2689,0.7311)(\sigma(-1), \sigma(1)) \approx (0.2689, 0.7311): shift by m=1001m = 1001 to get e−1/(e−1+1)e^{-1}/(e^{-1} + 1) and 1/(e−1+1)1/(e^{-1} + 1). (2) The positives score {0.9,0.7}\{0.9, 0.7\} and the negatives {0.8,0.6}\{0.8, 0.6\}. Of the 4 pairs, 3 put the positive higher (0.9>0.80.9 > 0.8, 0.9>0.60.9 > 0.6, 0.7>0.60.7 > 0.6, but 0.7<0.80.7 < 0.8), so AUC=3/4=0.75\mathrm{AUC} = 3/4 = 0.75.

    4. Worked example #

    Softmax cross-entropy. z=(2,1,0)z = (2, 1, 0), and the true class is the first one (index 0 in code).

    1. m=2m = 2, so z−m=(0,−1,−2)z - m = (0, -1, -2) and ∑jezj−m=1+0.3679+0.1353=1.5032\sum_j e^{z_j - m} = 1 + 0.3679 + 0.1353 = 1.5032.
    2. LSE⁡(z)=2+log⁡1.5032\operatorname{LSE}(z) = 2 + \log 1.5032, and log⁡1.5032=0.4076\log 1.5032 = 0.4076, so LSE⁡(z)=2.4076\operatorname{LSE}(z) = 2.4076.
    3. Divide (1,0.3679,0.1353)(1, 0.3679, 0.1353) by 1.50321.5032: p=(0.6652,0.2447,0.0900)p = (0.6652, 0.2447, 0.0900).
    4. ℓ=LSE⁡(z)−z1=2.4076−2=0.4076\ell = \operatorname{LSE}(z) - z_1 = 2.4076 - 2 = 0.4076.
    5. ∂ℓ/∂z=p−e1=(−0.3348,0.2447,0.0900)\partial \ell / \partial z = p - e_1 = (-0.3348, 0.2447, 0.0900). The entries sum to 0.

    Logistic regression, one step. One feature, no bias: x=(1,2,−1)x = (1, 2, -1), y=(1,1,0)y = (1, 1, 0), start at w=0w = 0.

    1. z=0z = 0, so p=σ(0)=(0.5,0.5,0.5)p = \sigma(0) = (0.5, 0.5, 0.5), and L(0)=log⁡2=0.6931L(0) = \log 2 = 0.6931.
    2. p−y=(−0.5,−0.5,0.5)p - y = (-0.5, -0.5, 0.5), so x⊤(p−y)=1⋅(−0.5)+2⋅(−0.5)+(−1)⋅0.5=−2x^\top(p - y) = 1 \cdot (-0.5) + 2 \cdot (-0.5) + (-1) \cdot 0.5 = -2, and ∇L=13x⊤(p−y)=−0.6667\nabla L = \frac13 x^\top(p - y) = -0.6667.
    3. With η=1\eta = 1: w=0−1⋅(−0.6667)=0.6667w = 0 - 1 \cdot (-0.6667) = 0.6667, so z=wx=(23,43,−23)z = wx = (\tfrac23, \tfrac43, -\tfrac23). Use softplus⁡(z)−z=softplus⁡(−z)\operatorname{softplus}(z) - z = \operatorname{softplus}(-z) for the two examples with y=1y = 1. The three terms softplus⁡(zi)−yizi\operatorname{softplus}(z_i) - y_i z_i are softplus⁡(−23)=0.4144\operatorname{softplus}(-\tfrac23) = 0.4144, softplus⁡(−43)=0.2340\operatorname{softplus}(-\tfrac43) = 0.2340 and softplus⁡(−23)=0.4144\operatorname{softplus}(-\tfrac23) = 0.4144 (here y=0y = 0). So L=13(0.4144+0.2340+0.4144)=0.3542L = \frac13(0.4144 + 0.2340 + 0.4144) = 0.3542.
    4. The step was safe: β≤x⊤x/(4n)=6/12=0.5\beta \le x^\top x/(4n) = 6/12 = 0.5, so any η≤2\eta \le 2 works.
    5. The data are separable (x>0  ⟺  y=1x > 0 \iff y = 1). If you keep going, ww grows without end: about 2.16 after 10 steps, 4.24 after 100 and 6.51 after 1000, while the loss falls toward 0 (0.077, 0.0096, 0.00099).

    k-means on a line. x=(0,1,4,5,6)x = (0, 1, 4, 5, 6), k=2k = 2, start at μ=(0,1)\mu = (0, 1).

    Iteration Clusters after (a) JJ after (a) μ\mu after (b) JJ after (b)
    1 {0}\{0\}, {1,4,5,6}\{1, 4, 5, 6\} 50 (0,4)(0, 4) 14
    2 {0,1}\{0, 1\}, {4,5,6}\{4, 5, 6\} 6 (0.5,5)(0.5, 5) 2.5
    3 unchanged: stop 2.5 (0.5,5)(0.5, 5) 2.5

    Check row 1. After (a), the point 1 is a center itself, and 4, 5, 6 are closer to 1 than to 0: J=0+0+32+42+52=50J = 0 + 0 + 3^2 + 4^2 + 5^2 = 50. After (b), the second center is the mean (1+4+5+6)/4=4(1 + 4 + 5 + 6)/4 = 4: J=0+(32+02+12+22)=14J = 0 + (3^2 + 0^2 + 1^2 + 2^2) = 14. JJ never goes up, as the theorem says.

    AUC with a tie. Scores s=(0.9,0.7,0.7,0.4,0.2)s = (0.9, 0.7, 0.7, 0.4, 0.2), labels y=(1,0,1,1,0)y = (1, 0, 1, 1, 0), so n+=3n_+ = 3 and n−=2n_- = 2.

    1. Ascending ranks: 0.2→10.2 \to 1, 0.4→20.4 \to 2, the two scores 0.70.7 share (3+4)/2=3.5(3 + 4)/2 = 3.5, 0.9→50.9 \to 5.
    2. R+=5+3.5+2=10.5R_+ = 5 + 3.5 + 2 = 10.5 and n+(n++1)/2=6n_+(n_+ + 1)/2 = 6, so AUC=(10.5−6)/6=0.75\mathrm{AUC} = (10.5 - 6)/6 = 0.75.
    3. Pair check: 0.90.9 beats both negatives (2); the positive 0.70.7 ties the negative 0.70.7 (½) and beats 0.20.2 (1); 0.40.4 beats only 0.20.2 (1). Total 4.5 of 6 pairs. ✓

    5. In code #

    NumPy, SciPy and scikit-learn check every number above. Softmax and cross-entropy:

    import numpy as np
    from scipy.special import softmax, logsumexp
    
    z = np.array([1000.0, 1001.0])
    with np.errstate(over="ignore", invalid="ignore"):
        print(np.exp(z) / np.exp(z).sum())        # [nan nan]
    print(softmax(z))                             # [0.26894142 0.73105858]
    
    z = np.array([2.0, 1.0, 0.0])
    p = softmax(z)
    print(p.round(4), round(logsumexp(z) - z[0], 4))   # [0.6652 0.2447 0.09  ] 0.4076
    print((p - np.eye(3)[0]).round(4))                 # [-0.3348  0.2447  0.09  ]
    
    with np.errstate(divide="ignore"):
        print(-np.log(softmax([0.0, -1000.0])[1]), logsumexp([0.0, -1000.0]) + 1000.0)   # inf 1000.0
    

    One gradient step of logistic regression:

    from scipy.special import expit                    # the sigmoid
    
    x = np.array([1.0, 2.0, -1.0]); y = np.array([1.0, 1.0, 0.0])
    loss = lambda w: np.mean(np.logaddexp(0, w * x) - y * w * x)   # softplus(z) - y z
    grad = lambda w: np.mean((expit(w * x) - y) * x)
    print(round(loss(0.0), 4), round(grad(0.0), 4))     # 0.6931 -0.6667
    w1 = 0.0 - 1.0 * grad(0.0)                          # one step with eta = 1
    print(round(w1, 4), round(loss(w1), 4))             # 0.6667 0.3542
    

    k-means and AUC:

    from sklearn.cluster import KMeans
    from sklearn.metrics import roc_auc_score
    from scipy.stats import mannwhitneyu
    
    X = np.array([[0.0], [1.0], [4.0], [5.0], [6.0]])
    km = KMeans(n_clusters=2, init=np.array([[0.0], [1.0]]), n_init=1).fit(X)
    print(km.cluster_centers_.ravel(), km.labels_, km.inertia_)   # [0.5 5. ] [0 0 1 1 1] 2.5
    
    print(roc_auc_score([1, 0, 1, 0], [0.9, 0.8, 0.7, 0.6]))      # 0.75
    yy = np.array([1, 0, 1, 1, 0])
    s = np.array([0.9, 0.7, 0.7, 0.4, 0.2])
    U = mannwhitneyu(s[yy == 1], s[yy == 0]).statistic           # 4.5 winning pairs
    print(roc_auc_score(yy, s), U / (3 * 2))                       # 0.75 0.75
    

    The MLP table, checked with central differences on every parameter:

    rng = np.random.default_rng(0)
    B, d, h, K = 4, 3, 5, 2
    Xb = rng.normal(size=(B, d)); yb = np.array([0, 1, 1, 0])
    P0 = {"W1": rng.normal(size=(d, h)), "b1": np.zeros(h), "W2": rng.normal(size=(h, K)), "b2": np.zeros(K)}
    
    def forward(P):
        H = Xb @ P["W1"] + P["b1"]; A = np.maximum(H, 0)
        Z = A @ P["W2"] + P["b2"]
        L = np.mean(logsumexp(Z, axis=1) - Z[np.arange(B), yb])
        return L, (H, A, Z)
    
    def backward(P, cache):
        H, A, Z = cache
        G = (softmax(Z, axis=1) - np.eye(K)[yb]) / B        # dL/dZ, shape (B, K)
        dA = G @ P["W2"].T
        dH = dA * (H > 0)
        return {"W2": A.T @ G, "b2": G.sum(0), "W1": Xb.T @ dH, "b1": dH.sum(0)}
    
    L, cache = forward(P0)
    g = backward(P0, cache)
    eps, worst = 1e-6, 0.0
    for name, Wt in P0.items():
        for idx in np.ndindex(Wt.shape):
            old = Wt[idx]
            Wt[idx] = old + eps; Lp = forward(P0)[0]
            Wt[idx] = old - eps; Lm = forward(P0)[0]
            Wt[idx] = old
            num = (Lp - Lm) / (2 * eps)
            worst = max(worst, abs(num - g[name][idx]) / max(1e-12, abs(num) + abs(g[name][idx])))
    print({k: v.shape == P0[k].shape for k, v in g.items()})   # {'W2': True, 'b2': True, 'W1': True, 'b1': True}
    print(f"largest relative error: {worst:.1e}")               # about 1e-8: correct
    

    6. Where it shows up #

    • The logistic loss is the last layer of every binary classifier. Platt scaling (calibration) is one-dimensional logistic regression on the scores. RLHF reward models train with the Bradley–Terry loss −log⁡σ(rchosen−rrejected)-\log\sigma(r_{\text{chosen}} - r_{\text{rejected}}): the logistic loss on a score difference (lesson 11).
    • Softmax cross-entropy is the LLM pretraining loss over a vocabulary of about 10510^5 tokens. Fused kernels compute the LSE without storing the B×VB \times V probabilities. Attention uses the same max shift, and FlashAttention computes it online, block by block (ML systems 04).
    • AUC scores detectors of rare events: fraud, earthquake detection, extreme-weather or flood warnings. With rare positives, report PR-AUC as well.
    • k-means builds codebooks: vector quantization, the discrete speech targets of HuBERT (k-means on acoustic features), weather-regime clustering of pressure fields, and the initialization of Gaussian mixture models.
    • Hand-written backprop is needed for custom layers and fused kernels, for adjoints of simulators, and to debug a gradient when autograd differentiates a function you did not intend.

    7. Common mistakes #

    • Overflow and log⁡0\log 0. A naive softmax or sigmoid; cross-entropy computed from probabilities. Work with logits: the max shift, LSE and softplus.
    • Broadcasting (n, 1) - (n,) gives an (n,n)(n, n) matrix without any error. Keep p and y both of shape (n,), and assert the shapes.
    • Mean versus sum. A mean loss with a summed gradient makes the step nn times too large.
    • Penalizing the bias, or not standardizing the features before gradient descent.
    • Expecting convergence on separable data with λ=0\lambda = 0. There is no minimizer; ∥w∥\|w\| grows for ever.
    • k-means. An empty cluster gives nan; only one random start; a Python loop for the distances; negative squared distances from round-off.
    • AUC. Ignoring ties (the answer then depends on the sort order); a fixed grid of thresholds instead of exact ranks; no check for a single class.
    • Backprop. Not summing the bias gradient over the batch; dividing by BB twice (in GG and again in dWdW); a gradient check in float32 or with ε=10−12\varepsilon = 10^{-12} (round-off then dominates).
    • Changing the caller's data. X -= X.mean(0) modifies the input array in place; use X = X - X.mean(0).

    8. Check yourself #

    1. Show that softmax⁡(z+c1)=softmax⁡(z)\operatorname{softmax}(z + c\mathbf 1) = \operatorname{softmax}(z). Why choose c=−max⁡jzjc = -\max_j z_j?
      Answerezk+c/∑jezj+c=ecezk/(ec∑jezj)e^{z_k + c}/\sum_j e^{z_j + c} = e^c e^{z_k}/(e^c \sum_j e^{z_j}), and ece^c cancels. With c=−max⁡jzjc = -\max_j z_j every exponent is ≤0\le 0 (no overflow), and the largest term is 1, so the sum is ≥1\ge 1 (no division by 0, and its log is ≥0\ge 0).
    2. Logistic regression with labels yi∈{−1,+1}y_i \in \{-1, +1\} and zi=xi⊤wz_i = x_i^\top w: write the mean loss and its gradient.
      AnswerL=1n∑ilog⁡(1+e−yizi)L = \frac1n\sum_i \log(1 + e^{-y_i z_i}) and ∇wL=−1n∑iyi σ(−yizi) xi\nabla_w L = -\frac1n\sum_i y_i\,\sigma(-y_i z_i)\,x_i, because ddzlog⁡(1+e−yz)=−y σ(−yz)\frac{d}{dz}\log(1 + e^{-yz}) = -y\,\sigma(-yz).
    3. Why can the update step (b) of k-means not increase JJ?
      AnswerWith the assignment fixed, the cost of cluster jj is a convex quadratic in μj\mu_j; its gradient 2∑i:ci=j(μj−xi)2\sum_{i: c_i = j}(\mu_j - x_i) is zero at the mean, so the mean minimizes it. The old center is one of the candidates, so the cost cannot go up.
    4. All scores are equal. What is the AUC? What if you negate all scores of a model with AUC 0.8?
      Answer0.5: every (positive, negative) pair is a tie and counts ½. Negating the scores reverses every strict order and keeps the ties, so the AUC becomes 1−0.8=0.21 - 0.8 = 0.2.
    5. In the 2-layer MLP, what are the shapes of GG, dAdA and db1db_1, and how do you compute db1db_1?
      AnswerGG is B×KB \times K, dA=GW2⊤dA = GW_2^\top is B×hB \times h, and db1db_1 has shape (h,)(h,), like b1b_1: db1=∑rowsdHdb_1 = \sum_{\text{rows}} dH, the sum of dH=dA⊙1[H>0]dH = dA \odot \mathbf 1[H > 0] over the batch.
    6. Your logistic regression on separable data with λ=0\lambda = 0 never converges: the loss keeps falling and ∥w∥\|w\| keeps growing. Bug or expected? What do you do?
      AnswerExpected: there is no finite minimizer, and the direction of ww tends to the max-margin direction. Add λ>0\lambda > 0, or stop early (for example on a validation loss).
    Recall cardsAnswer in your head first, then reveal. Add the cards to your review deck to see them again just before you would forget.
    1. Why does subtracting max⁡jzj\max_j z_j from the logits not change the softmax?

    2. Write log-sum-exp in its stable form.

    3. What is the gradient of the softmax cross-entropy ℓ=LSE⁡(z)−zy\ell = \operatorname{LSE}(z) - z_y with respect to the logits?

    4. What is the gradient of the mean logistic loss with the penalty λ2∥w∥2\frac{\lambda}{2}\|w\|^2?

    5. What happens to logistic regression on linearly separable data with λ=0\lambda = 0?

    6. Why does Lloyd's algorithm never increase the k-means objective?

    7. What is AUC as a probability?

    8. Compute AUC from ranks.

    9. How do you get the weight gradient of a linear layer Y=XW+bY = XW + b from the upstream gradient dYdY?

    10. Which relative error in a central-difference gradient check means "correct"?

    Open the review →

    9. Interview angle #

    Which tool for which wording.

    The problem says … Try …
    "probabilities from scores", "softmax" subtract the row maximum, axis=-1, keepdims=True
    "loss from logits", "cross-entropy" LSE⁡(z)−zy\operatorname{LSE}(z) - z_y; binary: np.logaddexp(0, z) - y * z
    "pairwise distances", "nearest center" ∥x∥2−2XM⊤+∥μ∥2\|x\|^2 - 2XM^\top + \|\mu\|^2, clip at 0, argmin(axis=1)
    "AUC without scikit-learn" average ranks (scipy.stats.rankdata), rank formula; pair count as the test
    "backprop by hand" the shape table; dW=(input)⊤(upstream)dW = (\text{input})^\top (\text{upstream}); sum the bias gradient
    "verify the gradient" central differences in float64, relative error
    "too much data for memory" mini-batches: SGD for logistic regression, mini-batch k-means

    The routine to say aloud. "Inputs are … with shapes …; I return … The loss in matrix form is … The stable form uses … I vectorize over examples with one matrix product. I test a tiny case by hand, compare with scikit-learn, and run a gradient check. The cost is …"

    Typical questions and short model answers.

    • "Implement logistic regression." Write the loss with softplus, the gradient X⊤(p−y)/n+λwX^\top(p - y)/n + \lambda w, and gradient descent with a fixed step η≤1/β\eta \le 1/\beta after standardizing. Follow-ups: why not squared error on σ(z)\sigma(z) (it is not convex in ww, and its gradient vanishes when σ\sigma saturates); multiclass (softmax, gradient X⊤(P−Y)/nX^\top(P - Y)/n); Newton's method (a few steps, each solving a d×dd \times d system).
    • "Implement k-means." Lloyd's algorithm with vectorized distances, k-means++ seeding, an empty-cluster rule, and several starts. Follow-ups: choosing kk (elbow of JJ, silhouette score); clusters that are not round (Gaussian mixtures, spectral clustering); cost O(nkd)O(nkd) per iteration; k-means as the zero-variance limit of EM for a Gaussian mixture.
    • "Compute AUC without scikit-learn." Sort, average ranks for ties, rank formula, O(nlog⁡n)O(n \log n); test against the O(n+n−)O(n_+ n_-) pair count. Follow-ups: imbalance (PR-AUC); calibration (AUC ignores it, because only the order matters).
    • "Write softmax." Subtract the max along the class axis. Follow-ups: float16 (keep the shift and the sum in float32); a temperature TT, softmax⁡(z/T)\operatorname{softmax}(z/T); the online version used by FlashAttention.
    • "Backprop through a 2-layer MLP." Give the shape table, then G=(P−Y)/BG = (P - Y)/B and the four gradients. Follow-ups: how you verify it (gradient check); the memory of the stored activations (HH or the ReLU mask, and AA).

    Typical follow-ups on any of them. What is the time and memory cost? What breaks with float32? How do you test it without a library? What changes for a sum loss instead of a mean?

    10. Practice #

    The notebook for this lesson runs in Colab. Each exercise has a check cell and a folded solution:

    1. log_softmax and cross_entropy from logits, checked against scipy.special on random and extreme logits.
    2. fit_logreg by gradient descent, checked against sklearn.linear_model.LogisticRegression with the matching penalty C=1/(nλ)C = 1/(n\lambda).
    3. kmeans with k-means++ seeding and vectorized distances; its inertia must be within 1% of scikit-learn's on the same data.
    4. roc_auc with ties by the rank formula, checked against sklearn.metrics.roc_auc_score and the O(n+n−)O(n_+ n_-) pair count.
    5. mlp_backward for the 2-layer MLP, checked with your own central-difference gradient check.

    Timed practice (20 minutes each, in an empty file, no help): write each of the five again and test it against the library. Public problem collections such as Deep-ML (sections on machine learning and deep learning) have short versions of the same tasks: softmax, logistic regression, k-means, a single neuron with backpropagation.

    Say it aloud (3 minutes): explain to an imaginary interviewer why you subtract the maximum in softmax, why you never compute −log⁡(softmax⁡(z)y)-\log(\operatorname{softmax}(z)_y), and what the gradient with respect to the logits is.

    11. Watch and read #

    How the ROC curve is built by moving the threshold, and what the area means.
    Open on YouTube · the player loads from youtube-nocookie.com when you press play
    The assign and update steps on a small example, and why you try several starts.
    Open on YouTube · the player loads from youtube-nocookie.com when you press play
    The model and the S-shaped curve; this lesson adds the loss, the gradient and the code.
    Open on YouTube · the player loads from youtube-nocookie.com when you press play
    Manual backprop through an MLP, tensor by tensor, with a check against autograd; the cross-entropy part matches section 3.5.
    Open on YouTube · the player loads from youtube-nocookie.com when you press play