ML coding in NumPy: softmax, logistic regression, k-means, AUC, MLP backprop
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: examples, features, classes,
batch size, hidden width; 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 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):
- Clarify (3 min). Shapes and dtypes; label coding ( or ); mean or sum loss; regularization (never on the bias); what to return. Write the signature with shape comments.
- Math first (5 min). Write each formula in matrix form as a comment, one line each.
- Stable form. Mark every
expandlog. Use the max shift, log-sum-exp or softplus. - Vectorize. One matrix product per layer or per distance table. Loop over iterations, never over examples.
- 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.
- 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: , with .
- Logistic regression in one dimension without bias has one weight: .
- k-means on a line with 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
into probabilities . In float64 the largest number is about
, so is inf for , and rounds to 0 for . Both
happen with real logits (for example after a bad initialization, or with large attention scores).
Move the logits, or pick an example. The left half of the table is the naive computation
in float64; the right half first subtracts . 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 with class 2: the naive cross-entropy is inf, but
gives the right value, about 1000.
In float64, what does the naive np.exp(z) / np.exp(z).sum() return for ?
Every is below the smallest float64 (about ), so each one rounds to 0, the sum is 0, and is nan. Underflow is not harmless when every term underflows. After the shift by , the exponents are and the result is correct.
3. Definitions and main results #
3.1 Stable softmax, log-softmax and cross-entropy #
For logits : .
Shift invariance. for every number .
Proof. , and cancels.
Take . Then every exponent is , so nothing overflows. The largest term is , so the sum lies in : no division by 0, and its is .
Log-sum-exp. . Then .
Cross-entropy from logits. For the true class , the cross-entropy loss is
. Never compute : a tiny
rounds to 0, and the loss becomes . Example: with has
, but of the rounded probability gives inf.
Gradient. , where is the one-hot vector of class .
Proof. , and if , else 0.
For a mean over a batch of examples, divide by . The entries of sum to 0.
Binary case. Compute the sigmoid as for and as
for , so exp never sees a large positive argument. The binary
cross-entropy of a logit with label is , where
the softplus is
(np.logaddexp(0, z) computes it).
Why : and . Mix the two with and .
3.2 Logistic regression #
Data , labels , weights , bias . Logits , probabilities (elementwise). The loss with an L2 penalty is
Gradient, step by step.
- , so .
- (row of ) and .
- Average over and add the penalty:
The shapes check it: is and is , so the product is , the shape of .
Convexity and the step size. The Hessian in is with . It is positive semidefinite, so is convex. Since , the gradient is Lipschitz with a constant (the smoothness constant) . (To include , append a column of ones to .) Gradient descent with a step size never increases the loss (Optimization 02). Standardize the features first: it makes 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 , the loss can come as close to 0 as you like but never reaches it: there is no minimizer. Gradient descent makes grow without bound (about like ), and the direction of tends to the max-margin direction (Soudry et al., 2018). Use or stop early.
Press +10, +100 and +1000 to run gradient descent. The violet line is the decision line ; the dashed lines are where (blue) and (orange). On separable data with the loss keeps falling and keeps growing, so the dashed lines close in on the decision line. Set and run again: stops growing. Switch to overlapping data: now a finite minimizer exists even with .
X has shape (n, d), and p, y have shape (n,). Which line is the gradient of the mean logistic loss with the penalty ?
The gradient is , shape . X @ (p - y) fails, because the shapes and do not match. y - p has the wrong sign: the step would go uphill. Without / n the data term is the gradient of the sum, not the mean, so the step is times too large for a step size chosen for the mean loss.
3.3 k-means #
Points , centers , and an assignment for each point. The objective, called the inertia, is
Lloyd's algorithm alternates two steps until the assignment stops changing:
- (a) assign: (on a tie, keep the current center);
- (b) update: set each to the mean of the points with .
Theorem. never increases, and Lloyd's algorithm stops after a finite number of steps.
Proof.
- Step (a) chooses the best center for each point with fixed, so does not increase. If some changes, that point moved to a strictly closer center (a tie keeps the old one), so strictly decreases.
- Step (b) minimizes over with fixed. The cost of cluster , , is a convex quadratic in . Its gradient is zero at the mean. The old center is one of the candidates, so does not increase.
- After step (b), depends only on the partition (the centers are its means). By steps 1–2, strictly decreases every time the partition changes, so no partition appears twice.
- There are at most partitions, so the algorithm stops.
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 .
Vectorized distances. . With the centers as the rows of (), this needs one matrix product , of shape , plus two broadcast vectors. Clip at 0, because round-off can make tiny values negative. The cost per iteration is .
k-means++ seeding. Pick the first center uniformly from the data. Pick each next center at random with probability proportional to , the squared distance from to its nearest chosen center. Then the expected is within a factor of the optimum (Arthur and Vassilvitskii, 2007).
Empty cluster. If no point is assigned to center , its mean is . Re-seed it, for example at the point that is farthest from its own center.
Press "Next" to run one half-step at a time, and watch in the chart. Each point takes the colour of its cluster; thin lines join it to its center. 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.
What does step (b) of Lloyd's algorithm minimize?
With the assignment fixed, each cluster's cost is a convex quadratic in , minimized by the mean. Step (a) is the other half: it minimizes over the assignments with the centers fixed. Each step minimizes over one block of variables, so cannot go up.
3.4 ROC AUC #
Scores (higher means "more likely positive") and labels , with positives and 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 . AUC is the area under it.
Theorem (AUC as a probability). For a random positive with score and a random negative with score :
This is the Mann–Whitney statistic divided by .
Why this is true. When the threshold passes one negative, the curve moves right by . The height at that moment is the fraction of positives that score above this negative. So the area added is (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, . Sort all scores in ascending order and give rank 1 to the smallest; tied scores get the average of their ranks. Let be the sum of the ranks of the positives. Then
Proof (no ties).
- A positive with rank has scores below it: some are negatives, some are positives.
- Over all positives, the number of (positive, positive) pairs with one below the other is .
- So the number of (positive, negative) pairs with the positive above is the total minus the (positive, positive) pairs: .
- Divide by the pairs.
With average ranks, a tied (positive, negative) pair adds , as in the theorem.
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 , 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). . 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 count over all pairs is a good test oracle for your version.
Scores have the labels . What is the AUC?
There are pairs. The positive 0.8 beats both negatives (2). The positive 0.6 ties the negative 0.6 (½) and beats 0.3 (1). Total 3.5 of 4, so 0.875. Counting the tie as 0 gives 0.75, and as 1 gives 1.0; the correct weight is ½. Rank check: ranks 4, 2.5, 2.5, 1, so and .
3.5 MLP backprop #
A 2-layer MLP on a batch with integer labels , hidden width and classes. is the one-hot label matrix, is the elementwise product, , and means .
| Forward | Shape | Backward | Shape |
|---|---|---|---|
| , | , | ||
| , by rows | |||
| scalar | , | , |
Read the backward column from the top: it runs from the loss back to the input.
- is the cross-entropy gradient of section 3.1, one row per example, divided by because is a mean.
- For : and (Linear algebra 16 derives these rules).
- ReLU passes the gradient where and blocks it elsewhere.
Two rules catch most bugs: every gradient has the shape of its parameter, and = (layer input) × (upstream gradient). The bias gradient sums over the batch, because is added to every row.
Gradient check. Compare entries with the central difference , in float64 with ; is the unit vector of entry . Look at the relative error : below about means correct; about 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).
In the 2-layer MLP, what is ?
is , and the gradient must have the same shape. The rule "input$^\top$ × upstream" gives : . is its transpose. is the gradient with respect to the input , not . skips the second layer and the ReLU.
Answers to section 1. (1) [nan, nan]: both exponentials overflow to inf, and inf/inf is nan.
The true value is : shift by to get
and . (2) The positives score and the negatives
. Of the 4 pairs, 3 put the positive higher (, , , but
), so .
4. Worked example #
Softmax cross-entropy. , and the true class is the first one (index 0 in code).
- , so and .
- , and , so .
- Divide by : .
- .
- . The entries sum to 0.
Logistic regression, one step. One feature, no bias: , , start at .
- , so , and .
- , so , and .
- With : , so . Use for the two examples with . The three terms are , and (here ). So .
- The step was safe: , so any works.
- The data are separable (). If you keep going, 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. , , start at .
| Iteration | Clusters after (a) | after (a) | after (b) | after (b) |
|---|---|---|---|---|
| 1 | , | 50 | 14 | |
| 2 | , | 6 | 2.5 | |
| 3 | unchanged: stop | 2.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: . After (b), the second center is the mean : . never goes up, as the theorem says.
AUC with a tie. Scores , labels , so and .
- Ascending ranks: , , the two scores share , .
- and , so .
- Pair check: beats both negatives (2); the positive ties the negative (½) and beats (1); beats only (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 : the logistic loss on a score difference (lesson 11).
- Softmax cross-entropy is the LLM pretraining loss over a vocabulary of about tokens. Fused kernels compute the LSE without storing the 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 . 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 matrix without any error. Keeppandyboth of shape(n,), andassertthe shapes. - Mean versus sum. A mean loss with a summed gradient makes the step times too large.
- Penalizing the bias, or not standardizing the features before gradient descent.
- Expecting convergence on separable data with . There is no minimizer; 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 twice (in and again in ); a gradient check in float32 or with (round-off then dominates).
- Changing the caller's data.
X -= X.mean(0)modifies the input array in place; useX = X - X.mean(0).
8. Check yourself #
- Show that . Why choose ?
Answer
, and cancels. With every exponent is (no overflow), and the largest term is 1, so the sum is (no division by 0, and its log is ). - Logistic regression with labels and : write the mean loss and
its gradient.
Answer
and , because . - Why can the update step (b) of k-means not increase ?
Answer
With the assignment fixed, the cost of cluster is a convex quadratic in ; its gradient is zero at the mean, so the mean minimizes it. The old center is one of the candidates, so the cost cannot go up. - All scores are equal. What is the AUC? What if you negate all scores of a model with AUC 0.8?
Answer
0.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 . - In the 2-layer MLP, what are the shapes of , and , and how do you compute ?
Answer
is , is , and has shape , like : , the sum of over the batch. - Your logistic regression on separable data with never converges: the loss keeps falling
and keeps growing. Bug or expected? What do you do?
Answer
Expected: there is no finite minimizer, and the direction of tends to the max-margin direction. Add , or stop early (for example on a validation loss).
Why does subtracting from the logits not change the softmax?
: the factor is in the numerator and the denominator and cancels.
Write log-sum-exp in its stable form.
with .
What is the gradient of the softmax cross-entropy with respect to the logits?
: the predicted probabilities minus the one-hot vector of the true class.
What is the gradient of the mean logistic loss with the penalty ?
, with ; and .
What happens to logistic regression on linearly separable data with ?
There is no minimizer: the loss tends to 0, grows without bound, and the direction of tends to the max-margin direction.
Why does Lloyd's algorithm never increase the k-means objective?
Step (a) picks the nearest center for each point (best for fixed ); step (b) moves each center to the mean (best for fixed ).
What is AUC as a probability?
for a random positive and a random negative.
Compute AUC from ranks.
, with ascending ranks, average ranks for ties, and the sum of the positives' ranks.
How do you get the weight gradient of a linear layer from the upstream gradient ?
(input transposed times upstream); is the sum of the rows of ; and .
Which relative error in a central-difference gradient check means "correct"?
Below about (float64, ); about or more means a bug.
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" | ; binary: np.logaddexp(0, z) - y * z |
| "pairwise distances", "nearest center" | , 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; ; 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 , and gradient descent with a fixed step after standardizing. Follow-ups: why not squared error on (it is not convex in , and its gradient vanishes when saturates); multiclass (softmax, gradient ); Newton's method (a few steps, each solving a system).
- "Implement k-means." Lloyd's algorithm with vectorized distances, k-means++ seeding, an empty-cluster rule, and several starts. Follow-ups: choosing (elbow of , silhouette score); clusters that are not round (Gaussian mixtures, spectral clustering); cost 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, ; test against the 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 , ; the online version used by FlashAttention.
- "Backprop through a 2-layer MLP." Give the shape table, then and the four gradients. Follow-ups: how you verify it (gradient check); the memory of the stored activations ( or the ReLU mask, and ).
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:
log_softmaxandcross_entropyfrom logits, checked againstscipy.specialon random and extreme logits.fit_logregby gradient descent, checked againstsklearn.linear_model.LogisticRegressionwith the matching penalty .kmeanswith k-means++ seeding and vectorized distances; its inertia must be within 1% of scikit-learn's on the same data.roc_aucwith ties by the rank formula, checked againstsklearn.metrics.roc_auc_scoreand the pair count.mlp_backwardfor 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 , and what the gradient with respect to the logits is.
11. Watch and read #
- Stanford CS231n notes: Linear classification (softmax, cross-entropy and the numerical-stability trick) and Backpropagation, intuitions (gradients of matrix products by their shapes).
- Andrew Ng and Tengyu Ma, CS229 lecture notes: logistic regression and its gradient, Newton's method, and k-means.
- T. Fawcett, An introduction to ROC analysis, Pattern Recognition Letters (2006): ROC curves, AUC and its link to the Wilcoxon (Mann–Whitney) statistic, efficient computation with ties.
- D. Arthur and S. Vassilvitskii, k-means++: the advantages of careful seeding (SODA 2007): the seeding rule and the guarantee.
- D. Soudry et al., The implicit bias of gradient descent on separable data (2018): why grows like and the direction tends to the max-margin solution.
- scikit-learn,
LogisticRegression(the parameter is the inverse penalty strength) androc_auc_score. - Andrej Karpathy, Neural Networks: Zero to Hero: the code of the lecture series, including the backprop exercise.
Runs in your browser with nothing to install. The notebook repeats the key demos and has exercises with checks; solutions are inside, folded.
No Google account needed: Python runs inside your browser. The first start downloads about 40 MB; your changes are saved in this browser only.