Ch. 04
Training Models
Linear and logistic regression from first principles. Normal equation, GD, Adam, regularization.
There is exactly one equation that solves linear regression in closed form, and almost nobody uses it. The reason is not that the math is wrong. The math is fine. The reason is that the equation needs to invert a (d, d) matrix, and once d is more than a few thousand the inversion takes longer than The core routine of training: check which way is downhill, take a small step, and repeat until the guesses are good.Full glossary → does to converge. So everyone uses gradient descent instead, a method that does not solve the problem and instead approximates it iteratively, and learns to live with the fact that the answer depends on the How big an adjustment the model makes to its numbers each time, after the gradient tells it which way to go.Full glossary →, the initialization, the A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary → size, the Letting past adjustments build up speed so training keeps rolling in a steady direction instead of zig-zagging.Full glossary →, and whether you remembered to standardize your features. This chapter is about why that trade is the right one, and about the family of training tricks that turns "approximate" into "good enough that you train transformers with it".
FIG 04.1 · Learning outcomes
By the end of this chapter you will be able to:
- Solve linear regression in three different ways (normal equation, SVD pseudo-inverse, gradient descent) and explain when each one is the right call.
- Implement batch, stochastic, and mini-batch gradient descent in 30 lines of NumPy and show the convergence path on a 2D loss surface.
- Pick a learning rate by reading the loss curve. Diagnose four failure modes (too high, too low, batch-size mismatch, unscaled features) from the curve alone.
- Add momentum and switch to Adam without copy-pasting the update rule from a blog.
- Apply ridge, lasso, and elastic net regularization, and explain why lasso drives weights to exactly zero while ridge only shrinks them.
- Implement logistic regression and softmax regression from scratch, derive the gradients, and connect them to the cross-entropy loss.
- Read a learning curve and decide whether to add more data, add more features, or regularize harder.
FIG 04.2 · What you need first
- Ch 0 — Math & Python prereqs — partial derivatives, matrix multiplication, the chain rule. If
∇θ MSE(θ)is not a recognizable object, work that section first. - Ch 3 — Classification — you need to know what a binary classifier is, what precision and recall measure, and what a confusion matrix looks like. The logistic and softmax sections of this chapter assume that.
- Ch 2 — End-to-end ML project — you need to have seen
train_test_splitandStandardScaleronce before, in context.
If you skipped another chapter: the calculus sections of this chapter will feel like noise. The "why $\sqrt{}$ in the learning rate update" type questions all live in another chapter.
FIG 04.3.1
Linear regression and the normal equation
A linear regression model predicts a target as a weighted sum of features plus a A single number that gets added to every guess, the same amount no matter what the inputs are.Full glossary → term:
where we append a constant to absorb the bias into . Given a The batch of examples the model actually studies and learns from.Full glossary → of examples stacked into the design matrix and targets , the mean-squared-error loss is:
Setting gives the normal equation:
That is the entire derivation. One line. The catch is that is an matrix inversion. For it is fine. For it is impossible.
Linear regression (normal equation): sklearn vs from scratch
Classical MLlin_reg = LinearRegression()
lin_reg.fit(X, y) # X: (m, d), y: (m, 1)
lin_reg.intercept_, lin_reg.coef_def fit_linear_regression(X, y):
m = X.shape[0]
X_b = np.c_[np.ones((m, 1)), X] # prepend bias column
theta = np.linalg.inv(X_b.T @ X_b) @ X_b.T @ y # (X^T X)^-1 X^T y
return thetafrom scratch: draft.md §1: fit_linear_regression (normal equation in NumPy)
- 1
lin_reg.fit(X, y)theta = inv(X_b.T @ X_b) @ X_b.T @ y, the closed-form normal equation - 2
fit_intercept=True (default), the bias termnp.c_[np.ones((m,1)), X] prepends the constant column - 3
lin_reg.coef_theta[1:] (the per-feature weights) - 4
lin_reg.intercept_theta[0] (the bias absorbed into theta)
What the one call hides
- sklearn does NOT use np.linalg.inv — it calls scipy.linalg.lstsq, an SVD-based pseudo-inverse, so it stays stable when X^T X is singular (collinear features) where the explicit inverse blows up.
- It minimizes the same OLS / MSE objective but never forms (X^T X)^-1 explicitly.
- Centering for the intercept is handled internally rather than by adding a ones column.
- Handles multi-output y and sample weights for free.
- Gotcha: np.linalg.inv silently returns garbage (huge weights) on collinear features; LinearRegression's SVD path degrades gracefully.
- Gotcha: No regularization at all (this is pure OLS); with more features than samples it is underdetermined.
- Gotcha: Explicit inversion is O(d^3) and infeasible past ~10^4 features, which is why the chapter pivots to gradient descent.
Prefer LinearRegression (or np.linalg.pinv) in production; the explicit-inverse scratch version is to see that .fit() is one line of linear algebra and to feel why the inverse breaks once features are collinear or numerous.
On the job: At work you call .fit(); the only hand-written part is feature engineering and deciding when OLS is even the right model.
FIG 04.3.2
Why we use gradient descent anyway
The normal equation gives the exact answer. So why does every deep learning framework train by The core routine of training: check which way is downhill, take a small step, and repeat until the guesses are good.Full glossary → instead?
Three reasons, in order of importance.
Reason 1: is too big. With features the matrix is , and inversion is . For a model with a million parameters that is operations per fit. Gradient descent is per step and converges in tens to hundreds of steps. The asymptotics flip somewhere around .
Reason 2: most loss functions are not quadratic. Logistic regression's loss has no closed-form solution. Neither does a general neural network's. The normal equation is a special-case benefit of squared loss; iterative numerical optimization is usually required, and A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary →-based methods are the workhorse when derivatives are available.
Reason 3: you can stream the data. Gradient descent (specifically the stochastic and mini-A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary → variants) processes one example or one mini-batch at a time. You never need to load the full matrix into memory. The normal equation needs the whole dataset materialized at once.
So gradient descent wins on three axes that all matter at scale. The normal equation is still useful: it is the right answer when is small, the data fits in RAM, the loss is quadratic, and you want a deterministic result without worrying about learning rates. That covers a lot of textbook regression problems and zero modern ML problems.
FIG 04.3.3
Batch gradient descent
The A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → of MSE with respect to is:
A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary → The core routine of training: check which way is downhill, take a small step, and repeat until the guesses are good.Full glossary → updates with this gradient, using the full The batch of examples the model actually studies and learns from.Full glossary → every step:
is the How big an adjustment the model makes to its numbers each time, after the gradient tells it which way to go.Full glossary →. Pick it too small and you crawl. Pick it too large and you oscillate or diverge. There is a Goldilocks band, and finding it is most of the practical art of training.
Library path (sklearn does not expose pure batch GD for regression; SGDRegressor is the closest, with batch-of-1):
from sklearn.linear_model import SGDRegressor
sgd_reg = SGDRegressor(max_iter=1000, tol=1e-5, penalty=None, eta0=0.01,
n_iter_no_change=100, random_state=42)
sgd_reg.fit(X, y.ravel())
print(sgd_reg.intercept_, sgd_reg.coef_)From-scratch path:
import numpy as np
def batch_gradient_descent(X: np.ndarray, y: np.ndarray, eta: float = 0.1,
n_epochs: int = 1000) -> np.ndarray:
"""Full-batch GD. Returns final theta and the loss history."""
m = X.shape[0]
X_b = np.c_[np.ones((m, 1)), X]
np.random.seed(42)
theta = np.random.randn(X_b.shape[1], 1)
losses = []
for epoch in range(n_epochs):
gradients = 2 / m * X_b.T @ (X_b @ theta - y)
theta = theta - eta * gradients
losses.append(float(np.mean((X_b @ theta - y) ** 2)))
return theta, losses
theta, losses = batch_gradient_descent(X, y, eta=0.1, n_epochs=1000)
print(theta) # [[4.21509616], [2.77011339]] — same answer as the normal equationThe from-scratch version returns the same parameters as the normal equation (up to about 1e-8) when run long enough. That is the sanity check. Always verify your gradient descent converges to the closed-form answer on a problem where the closed-form answer exists, before you trust it on problems where it does not.
FIG 04.3.4
Learning rate intuition
The How big an adjustment the model makes to its numbers each time, after the gradient tells it which way to go.Full glossary → is the most-tuned A setting you pick yourself before training starts, like the learning rate, batch size, or number of layers, which the model does not learn on its own.Full glossary → in machine learning and the one with the cleanest visual story. Géron's Figure 4-8 in Hands-On ML shows three runs of the same problem at , , and . The first one creeps toward the answer over hundreds of steps. The middle one lands in twenty. The last one oscillates, overshoots, and never settles.
The math: for a quadratic loss with Hessian eigenvalues bounded between and (smallest and largest), The core routine of training: check which way is downhill, take a small step, and repeat until the guesses are good.Full glossary → converges if and only if , and converges fastest at . The Hessian is the matrix of second derivatives of the loss; for a quadratic it is constant, and its eigenvalues measure how sharply the loss curves in each direction. The "condition number" — the ratio of the steepest to the shallowest direction — determines how fast: when is large the loss surface is an elongated bowl (a narrow canyon), and gradient descent zig-zags slowly down it.
You do not compute and in practice. You guess, then read the loss curve. The right behavior looks like:
- Smoothly decreasing, eventually flattening: is right
- Decreasing very slowly: too small
- Decreasing then bouncing around: too high or A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary → size too small
- Increasing or going to NaN: way too high
The first thing to try when training does not work is to drop by 10x. The second thing is to standardize your features so that does not blow up.
FIG 04.3.5
Stochastic and mini-batch gradient descent
A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary → The core routine of training: check which way is downhill, take a small step, and repeat until the guesses are good.Full glossary → has a problem: every update touches every training example. For that is a lot of compute per step, and the steps are deterministic, so the trajectory is smooth but slow.
Stochastic gradient descent (SGD) flips the trade-off: one example per step. The A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → is noisy because a single example is a terrible estimate of the full gradient. But the updates are cheap, and the noise itself helps the optimizer escape shallow local minima (and saddle points — flat spots where the gradient is zero but the surface curves up in some directions and down in others, which in high dimensions matter much more than local minima).
Mini-batch gradient descent is the middle path. Use a small subset of examples (16, 32, 64, 128, 256) per step. Reduces gradient variance compared to SGD, processes data faster than full-batch, runs efficiently on GPUs because the batch axis vectorizes cleanly.
Library path (sklearn's SGDRegressor with partial_fit for mini-batches):
from sklearn.linear_model import SGDRegressor
import numpy as np
sgd_reg = SGDRegressor(max_iter=1, warm_start=True, learning_rate="constant",
eta0=0.01, penalty=None, random_state=42)
batch_size = 32
for epoch in range(50):
perm = np.random.permutation(m)
for i in range(0, m, batch_size):
idx = perm[i:i + batch_size]
sgd_reg.partial_fit(X[idx], y[idx].ravel())From-scratch path (mini-batch with a A plan for changing the step size during training, usually shrinking it over time.Full glossary →):
def minibatch_gd(X: np.ndarray, y: np.ndarray, batch_size: int = 32,
n_epochs: int = 50, t0: float = 200, t1: float = 1000) -> np.ndarray:
"""Mini-batch GD with t0/(t+t1) learning rate schedule."""
m = X.shape[0]
X_b = np.c_[np.ones((m, 1)), X]
np.random.seed(42)
theta = np.random.randn(X_b.shape[1], 1)
step = 0
for epoch in range(n_epochs):
perm = np.random.permutation(m)
for i in range(0, m, batch_size):
idx = perm[i:i + batch_size]
xi = X_b[idx]
yi = y[idx]
gradients = 2 / len(idx) * xi.T @ (xi @ theta - yi)
eta = t0 / (step + t1)
theta -= eta * gradients
step += 1
return thetaThe How big an adjustment the model makes to its numbers each time, after the gradient tells it which way to go.Full glossary → schedule is one of many. It satisfies the Robbins-Monro conditions and : the rate has to keep summing to infinity so you can still travel any distance, but its squares have to stay finite so the noise eventually dies down. Together these guarantee The point where the wrongness score stops dropping and levels off, so more training doesn't help.Full glossary → on convex problems — loss surfaces shaped like a bowl, where any straight line between two points on the curve stays above it, so there is one global optimum and no local minimum to get stuck in. For deep learning, cosine decay with Starting training with tiny steps that grow for a little while before the main plan kicks in.Full glossary → is standard. For convex problems, this old-school schedule still works.
FIG 04.3.6
Polynomial regression and the bias-variance trade-off
Linear regression with linear features can only fit linear relationships. If your data is quadratic, you cheat: add as a One piece of information about an example that the model looks at when making a guess.Full glossary → and let the linear regression fit a parabola.
from sklearn.preprocessing import PolynomialFeatures
from sklearn.linear_model import LinearRegression
X = 6 * np.random.rand(100, 1) - 3
y = 0.5 * X**2 + X + 2 + np.random.randn(100, 1)
poly = PolynomialFeatures(degree=2, include_bias=False)
X_poly = poly.fit_transform(X) # adds x^2 as a second column
lin_reg = LinearRegression()
lin_reg.fit(X_poly, y)
print(lin_reg.intercept_, lin_reg.coef_)
# [1.78134581] [[0.93366893 0.56456263]] — close to (2, 1, 0.5)For a degree- polynomial in features, PolynomialFeatures generates new features. This blows up fast. Degree 2 with 100 features gives 5151 columns; degree 3 gives 176,851. At some point you are no longer doing linear regression; you are doing a non-parametric basis expansion and you need A penalty added to the wrongness score that discourages the model from leaning on big, fussy numbers, nudging it to stay simple.Full glossary →.
The deeper lesson is the A single number that gets added to every guess, the same amount no matter what the inputs are.Full glossary →-variance trade-off. Degree 1: high bias, low variance. The model is too rigid to fit the true curve. Degree 300: low bias, high variance. The model wiggles wildly to fit every training point, including the noise, and generalizes badly. Degree 2 (the truth): minimum total error.
Géron's Figure 4-14 shows all three on the same plot. Look at it. Then internalize that bias-variance is not just a textbook diagram; it is a daily diagnostic. When your test loss is much higher than your train loss, you have variance. When both are high, you have bias.
FIG 04.3.7
Learning curves: train vs valid as a function of dataset size
A learning curve plots train and validation loss as functions of The batch of examples the model actually studies and learns from.Full glossary → size. It is the single most useful diagnostic tool in classical ML.
from sklearn.model_selection import learning_curve
from sklearn.linear_model import LinearRegression
import numpy as np
train_sizes, train_scores, valid_scores = learning_curve(
LinearRegression(), X, y, train_sizes=np.linspace(0.01, 1.0, 40),
cv=5, scoring="neg_root_mean_squared_error")
train_errors = -train_scores.mean(axis=1)
valid_errors = -valid_scores.mean(axis=1)Three shapes to recognize.
Underfit (high A single number that gets added to every guess, the same amount no matter what the inputs are.Full glossary →): Both curves Squashing a multi-dimensional grid of numbers into a single long list.Full glossary → at a high error. Train and valid almost coincide. Adding data does nothing. The model is too simple. Fix: add features, use a deeper model, decrease A penalty added to the wrongness score that discourages the model from leaning on big, fussy numbers, nudging it to stay simple.Full glossary →.
Overfit (high variance): Train error is low, valid error is much higher. There is a gap. Adding data narrows the gap. Fix: more data, more regularization, simpler model.
Good fit: Both curves converge to a low error with a small remaining gap.
The shape of the gap is the diagnostic. The level of the curves is the second diagnostic. Together they tell you whether to add data, add features, or regularize.
FIG 04.3.8
Ridge, lasso, and elastic net regularization
To control variance, penalize large weights. Three flavors.
Ridge regression adds an L2 penalty on the weights to the loss:
The A single number that gets added to every guess, the same amount no matter what the inputs are.Full glossary → is not penalized (you do not want to shrink the intercept). Ridge shrinks all weights toward zero, smoothly. It has a closed form:
where is the identity with . Notice this also fixes the numerical issue with the plain normal equation: is invertible even when is singular.
Lasso regression uses L1 instead of L2:
The L1 norm has a corner at zero. The geometry of the optimization (Géron's Figure 4-19 shows this beautifully) means lasso drives some weights exactly to zero, producing a sparse solution. This is the key property: lasso does One piece of information about an example that the model looks at when making a guess.Full glossary → selection, ridge does not.
Elastic net combines both:
with controlling the L1/L2 mix.
Ridge regression (L2 closed form): sklearn vs from scratch
Classical MLridge = Ridge(alpha=0.7, solver="cholesky")
ridge.fit(X, y)
ridge.intercept_, ridge.coef_def ridge_regression(X, y, alpha=0.1):
m, d = X.shape
X_b = np.c_[np.ones((m, 1)), X]
A = np.eye(d + 1)
A[0, 0] = 0 # don't regularize the bias
return np.linalg.inv(X_b.T @ X_b + alpha * A) @ X_b.T @ yfrom scratch: draft.md §8: ridge_regression (modified normal equation)
- 1
Ridge(alpha=0.7)the alpha * A term added to X_b.T @ X_b - 2
solver='cholesky' closed-form pathinv(X_b.T @ X_b + alpha*A) @ X_b.T @ y - 3
sklearn not penalizing the interceptA[0,0] = 0 so the bias row is left unregularized - 4
ridge.coef_ / ridge.intercept_theta[1:] and theta[0]
What the one call hides
- sklearn auto-selects a solver (cholesky/svd/lsqr/sag) by data shape; only the cholesky path matches this exact closed form, others are iterative.
- It centers X and y to handle the intercept rather than literally zeroing a row of the penalty matrix.
- The L2 term doubles as numerical stabilization: X^T X + alpha*A is invertible even when X^T X is singular — sklearn relies on this silently.
- alpha scaling conventions and optional normalization differ from a hand-rolled penalty.
- Gotcha: alpha must be tuned per dataset and is sensitive to feature scale; unstandardized features make one global alpha penalize big-scale features less.
- Gotcha: Ridge shrinks but never zeros coefficients; reaching for it expecting feature selection (that is lasso) is a classic mix-up.
- Gotcha: Penalizing the intercept (forgetting A[0,0]=0) biases predictions toward zero; sklearn avoids this for you.
Use sklearn Ridge in production; the modified-normal-equation scratch shows ridge is literally OLS plus alpha on the diagonal, which also explains why ridge is numerically safer than plain LinearRegression.
On the job: At work you cross-validate alpha and standardize features; the closed form is sklearn's to compute.
Lasso has no closed form. The standard solvers are coordinate descent (sklearn's default) and proximal The core routine of training: check which way is downhill, take a small step, and repeat until the guesses are good.Full glossary →. Both are short to write but not as short as ridge.
FIG 04.3.9
Logistic regression: same linear function, different loss
Logistic regression is binary classification with a linear decision boundary. The model outputs a A number between 0 and 1 saying how confident the model is, where 1 means totally sure and 0.5 means a coin-flip.Full glossary →:
where is the A function that takes any number and squeezes it into a single confidence between 0 and 1.Full glossary → (also called logistic) function. It squashes any real number into . The decision rule is: predict 1 if , else 0.
The loss is binary A loss that measures how far a model's predicted chances are from the true answer.Full glossary → (also called log-loss), summed over training examples:
This is convex in . The A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → is the same surprisingly-clean form as linear regression:
That is the magic. The gradient of the cross-entropy of the sigmoid of a linear function looks like the gradient of MSE of a linear function. The math falls out when you do the chain rule and the term cancels with the cross-entropy denominator. Boyd & Vandenberghe (another chapter of Convex Optimization) walks through this.
No closed-form solution for logistic regression. The core routine of training: check which way is downhill, take a small step, and repeat until the guesses are good.Full glossary → (or Newton's method — a second-order method that uses the Hessian's curvature, not just the gradient, to jump straight toward the minimum; fast on small convex problems, but it has to invert the Hessian, so it does not scale) is the only path.
Logistic regression: sklearn vs from scratch
Classical MLlog_reg = LogisticRegression(penalty=None, max_iter=500)
log_reg.fit(X, y) # X: (m, d) standardized, y: (m,) 0/1
log_reg.predict_proba(X)[:, 1] # P(class=1)def sigmoid(z):
return 1.0 / (1.0 + np.exp(-np.clip(z, -500, 500)))
def logistic_grad(theta, X_b, y):
p = sigmoid(X_b @ theta)
return X_b.T @ (p - y) / X_b.shape[0]
def train(optimizer, X_b, y, n_steps=500):
theta = np.zeros((X_b.shape[1], 1))
for _ in range(n_steps):
g = logistic_grad(theta, X_b, y)
theta = optimizer.step(theta, g) # e.g. GD: theta - lr*g
return theta
# X_b = np.hstack([np.ones((m,1)), X]) -> theta[0] is the biasfrom scratch: lab/solution.py: sigmoid + logistic_grad + train
- 1
log_reg.fit(X, y)the for-loop in train() that repeatedly calls logistic_grad and optimizer.step for n_steps - 2
the sigmoid link + binary cross-entropy log_reg optimizes internallysigmoid(X_b @ theta) and the log-loss whose gradient is (p - y) - 3
the lbfgs/Newton gradient sklearn computeslogistic_grad = X_b.T @ (p - y) / m, the clean (p - y)*x form - 4
log_reg.coef_ and log_reg.intercept_theta[1:] are the coefs, theta[0] is the bias (the prepended ones column) - 5
log_reg.predict_proba(X)[:, 1]sigmoid(X_b @ theta)
What the one call hides
- Default solver is lbfgs (a quasi-Newton method using curvature), not the plain first-order GD in train(); it converges in far fewer iterations.
- penalty='l2' and C=1.0 are ON by default, so the out-of-the-box fit is regularized MAP, not the pure max-likelihood the scratch loop computes; pass penalty=None to match.
- max_iter/tol convergence checking and a ConvergenceWarning; the scratch loop just runs a fixed n_steps with no stopping criterion.
- Numerically stable internals; the scratch sigmoid hand-clips z to [-500, 500] to avoid exp overflow.
- Feature scaling is NOT done for you; both library and scratch need standardized X or convergence degrades.
- Gotcha: C is INVERSE regularization strength, so C=1.0 is a moderately regularized fit, not unpenalized; coef_ is shrunk, not the MLE.
- Gotcha: On perfectly separable data the unpenalized MLE diverges (weights -> infinity); the scratch GD and lbfgs then agree on the decision boundary but not on coefficient magnitude.
- Gotcha: predict() applies a hard 0.5 threshold; with imbalance or probability-based decisions that default is usually wrong.
Prefer sklearn in production; the from-scratch loop exists to show .fit() is gradient descent on the log-loss with the clean (p - y)x gradient, and so you can swap in custom optimizers sklearn does not expose.
On the job: You write the feature standardization, the class-weight/threshold choice, and a calibration check around the .fit() call; you almost never hand-roll the optimizer.
FIG 04.3.10
Softmax regression for multi-class classification
For classes, generalize the A function that takes any number and squeezes it into a single confidence between 0 and 1.Full glossary → to the A function that turns a list of raw scores into confidences for several options that add up to exactly 1 (100%).Full glossary →:
One score per class. Softmax converts scores into a A number between 0 and 1 saying how confident the model is, where 1 means totally sure and 0.5 means a coin-flip.Full glossary → distribution over classes (positive, sums to 1). The predicted class is .
You can sharpen or Squashing a multi-dimensional grid of numbers into a single long list.Full glossary → that distribution with a A dial that controls how much a model gambles on unlikely words versus sticking to the most likely one.Full glossary → (the name is borrowed from physics, where a hotter system spreads its energy more evenly), dividing each score before exponentiating: . As the distribution collapses onto a one-hot vector at the ; as it flattens to uniform. Plain softmax is the case. (This is the same scalar as the temperature Rewriting your numbers so that very large measurements and very small ones are put on a comparable footing.Full glossary → used for How well a model's stated confidence matches how often it's actually right.Full glossary → in the safety lens, applied for a different purpose.)
The loss is categorical A loss that measures how far a model's predicted chances are from the true answer.Full glossary →:
where is a A way to turn a category like a color into numbers the model can use, by making a yes/no slot for each option.Full glossary → of the label. Same A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → structure as the binary case: .
Softmax regression (multinomial logistic): sklearn vs from scratch
Classical MLsoftmax_reg = LogisticRegression(C=30) # multinomial by default in recent sklearn
softmax_reg.fit(X, y) # y: integer class labels 0..K-1
softmax_reg.predict_proba(X)def softmax(logits):
logits = logits - np.max(logits, axis=-1, keepdims=True) # stability
exp = np.exp(logits)
return exp / np.sum(exp, axis=-1, keepdims=True)
def softmax_regression(X, y, K, eta=0.1, n_epochs=1000):
m, d = X.shape
X_b = np.c_[np.ones((m, 1)), X]
Theta = np.random.randn(d + 1, K) * 0.01
Y = np.eye(K)[y] # one-hot encode labels
for _ in range(n_epochs):
P = softmax(X_b @ Theta)
gradients = 1 / m * X_b.T @ (P - Y)
Theta -= eta * gradients
return Thetafrom scratch: draft.md §10: softmax + softmax_regression
- 1
softmax_reg.fit(X, y)the n_epochs GD loop updating Theta with gradients = X_b.T @ (P - Y) / m - 2
the softmax link + categorical cross-entropy sklearn optimizesP = softmax(X_b @ Theta) and the cross-entropy whose gradient is (P - Y) - 3
internal multinomial label handlingY = np.eye(K)[y], the one-hot target matrix - 4
softmax_reg.predict_proba(X)softmax(X_b @ Theta), per-class probability rows that sum to 1 - 5
softmax_reg.coef_ (K x d)Theta[1:].T (per-class weight columns)
What the one call hides
- sklearn uses lbfgs/newton-cg on the full multinomial objective, not the fixed-n_epochs first-order GD of the scratch.
- C=1.0 L2 regularization is ON by default; the multinomial fit is regularized unless you set C very large (the draft uses C=30 to approximate the unpenalized fit).
- The numerically-stable max-subtraction in softmax is done internally; without it large logits overflow np.exp.
- Recent sklearn always does true multinomial softmax; older versions defaulted to one-vs-rest.
- Softmax is shift-invariant per row, so absolute Theta values are not identifiable the way the scratch's appear to be.
- Gotcha: C is inverse regularization (like binary logistic), so the default is a shrunk fit, not max-likelihood — the draft bumps C=30 to undo this.
- Gotcha: Omitting the max-subtraction in a hand-rolled softmax silently overflows to NaN on large logits.
- Gotcha: Probabilities are not calibrated off the training distribution; acting on predict_proba thresholds without a calibration check is the safety-lens pitfall.
Use sklearn LogisticRegression for multiclass in production; the scratch softmax_regression shows multinomial logistic is linear scores -> softmax -> cross-entropy with the identical clean (P - Y)x gradient, and forces you to write the numerically stable softmax every framework ships.
On the job: At work you call .fit() and instead spend time on the stable softmax/log-softmax when you write a custom loss head, plus calibration of the output probabilities.
FIG 04.3.11
Momentum: smooth out the noise
SGD's A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → is noisy. One example tells you very little about the true gradient. Average over batches and you get a better estimate. Average over time and you get Letting past adjustments build up speed so training keeps rolling in a steady direction instead of zig-zagging.Full glossary →.
The momentum update keeps a running velocity vector and updates parameters with that velocity rather than the raw gradient:
is the momentum coefficient, typically 0.9 or 0.99. When the gradient is consistent (always pointing the same way), the velocity grows and the optimizer accelerates. When the gradient flips sign (oscillating across a valley), the velocity cancels itself out and the oscillations damp.
The Distill explorable "Why Momentum Really Works" by Goh (2017) is the best visual on this. The takeaway: momentum gives quadratic acceleration on ill-conditioned problems. On a well-conditioned problem (eigenvalues all similar), momentum doesn't help much. On a badly-conditioned problem (one direction much steeper than another), momentum is the difference between converging in 10 steps and converging in 10,000.
Nesterov's accelerated gradient is a smarter variant: evaluate the gradient at the projected position instead of at . It is slightly faster in theory and slightly more annoying to code. Most practitioners use plain momentum or Adam.
SGD with momentum (and Nesterov): torch vs from scratch
DL glueopt = torch.optim.SGD([theta], lr=0.01, momentum=0.9) # heavy-ball
# opt = torch.optim.SGD([theta], lr=0.01, momentum=0.9, nesterov=True)
opt.zero_grad(); loss.backward(); opt.step()class GDMomentum(Optimizer):
def step(self, theta, grad):
if self.v is None: self.v = np.zeros_like(theta)
self.v = self.beta * self.v - self.lr * grad
return theta + self.v
class Nesterov(Optimizer):
def step(self, theta, grad):
if self.v is None: self.v = np.zeros_like(theta)
v_prev = self.v
self.v = self.beta * self.v - self.lr * grad
return theta - self.beta * v_prev + (1 + self.beta) * self.vfrom scratch: lab/solution.py: class GDMomentum.step and class Nesterov.step
- 1
momentum=0.9self.beta (the momentum coefficient), default 0.9 - 2
the internal velocity buffer (momentum_buffer)self.v, lazily initialized to zeros on first step - 3
lr=0.01self.lr inside v = beta*v - lr*grad - 4
opt.step() heavy-ball updatev = beta*v - lr*grad; theta = theta + v (GDMomentum) - 5
nesterov=True look-ahead variantNesterov.step closed form theta - beta*v_prev + (1+beta)*v
What the one call hides
- torch's buffer convention differs: it stores b = momentum*b + grad and updates theta -= lr*b, so the stored velocity sign/scaling is NOT the scratch's v = beta*v - lr*grad — the trajectories match but the buffers do not.
- dampening defaults to 0 (so the first momentum step uses the raw grad); torch's nesterov flag requires dampening=0 and momentum>0.
- weight_decay=0 by default (torch SGD can fold in L2 if you set it).
- autograd computes grad; the scratch is handed it.
- Gotcha: Convention mismatch: a from-scratch v = beta*v - lr*grad does not equal torch's momentum_buffer state, so you cannot compare buffers across implementations even when the path matches.
- Gotcha: nesterov=True silently no-ops/errors unless momentum>0 and dampening==0.
- Gotcha: momentum changes the effective step size; the stable lr for momentum=0.9 is much smaller than for plain GD, so reusing the GD lr can diverge.
Use torch.optim.SGD(momentum=...) in production (it still beats Adam for generalization on vision models, per Wilson et al.); the scratch versions show momentum is one running velocity vector and Nesterov is the same update at a look-ahead point.
On the job: You pick momentum/nesterov and the lr schedule and wire them into the training loop; the velocity bookkeeping is the library's.
FIG 04.3.12
Adam: adaptive learning rates per parameter
Letting past adjustments build up speed so training keeps rolling in a steady direction instead of zig-zagging.Full glossary → is one trick. Adaptive learning rates are another. Adam combines them.
Adam tracks two exponential moving averages per One of the model's internal numbers that gets adjusted as it learns.Full glossary →:
- — first moment (mean of gradients), same role as momentum
- — second moment (uncentered variance of gradients), used to scale the step size; this per-parameter rescaling on its own, without the momentum term, is the optimizer called RMSProp
Update:
A single number that gets added to every guess, the same amount no matter what the inputs are.Full glossary →-corrected:
Update:
Default hyperparameters: , , . They almost always work.
Adam vs AdamW. Plain Adam couples L2 Gently nudging a model's weights toward smaller values to keep the model simpler and less likely to overfit.Full glossary → into the A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → via the loss, which interacts badly with the adaptive Rewriting your numbers so that very large measurements and very small ones are put on a comparable footing.Full glossary →. AdamW decouples them: the weight decay is applied directly to the parameters at update time, independent of the gradient. For deep models, AdamW is the standard. For shallow ML, Adam is fine.
Adam optimizer: torch vs from scratch
DL glueopt = torch.optim.Adam([theta], lr=0.05, betas=(0.9, 0.999), eps=1e-8)
for _ in range(n_steps):
opt.zero_grad(); loss = loss_fn(theta); loss.backward(); opt.step()def step(self, theta, grad):
if self.m is None:
self.m = np.zeros_like(theta); self.v = np.zeros_like(theta)
self.t += 1
self.m = self.beta1 * self.m + (1 - self.beta1) * grad
self.v = self.beta2 * self.v + (1 - self.beta2) * grad ** 2
m_hat = self.m / (1 - self.beta1 ** self.t)
v_hat = self.v / (1 - self.beta2 ** self.t)
return theta - self.lr * m_hat / (np.sqrt(v_hat) + self.eps)from scratch: lab/solution.py: class Adam.step
- 1
Adam(..., betas=(0.9,0.999), eps=1e-8)Adam.__init__ storing lr, beta1=0.9, beta2=0.999, eps=1e-8, and m=v=None, t=0 - 2
first exp-moving-average state (exp_avg)self.m = beta1*m + (1-beta1)*grad - 3
second exp-moving-average state (exp_avg_sq)self.v = beta2*v + (1-beta2)*grad**2 - 4
torch's bias_correction1 / bias_correction2 from the step countm_hat = m/(1-beta1**t), v_hat = v/(1-beta2**t) - 5
opt.step() parameter updatetheta - lr * m_hat / (sqrt(v_hat) + eps) - 6
loss.backward() filling .gradthe grad argument passed into step()
What the one call hides
- Autograd computes the gradient for you; the scratch version is handed grad and only does the optimizer math.
- torch implements bias correction via per-step correction factors rather than literal beta**t each call (numerically equal).
- weight_decay defaults to 0 (this is plain Adam, NOT AdamW); any L2 you add couples into the moment estimates.
- amsgrad=False, maximize=False, and a fused/foreach kernel selection happen silently.
- torch keeps optimizer state on the parameter's device/dtype across the whole param group; the scratch class is one numpy array.
- Gotcha: lr=3e-4 is the famous default but is tuned for deep nets; on a 5-param problem it is too small (lab hint: all curves look identical) — use ~0.01-0.05.
- Gotcha: Bias correction is not optional: without dividing by (1-beta1**t) the first steps are ~10x too small because m and v start at zero.
- Gotcha: torch.optim.Adam is NOT AdamW; adding weight_decay gives L2-into-the-gradient, which interacts badly with the adaptive scaling.
Use torch.optim.Adam in any real training loop; the scratch step() proves Adam is two EMAs plus bias correction, so you can read and modify the exact update.
On the job: You write the training loop, lr schedule, grad clipping and param groups around opt.step(); the optimizer math itself you take from torch.
AdamW (decoupled weight decay): torch vs from scratch
DL glueopt = torch.optim.AdamW([theta], lr=0.05, weight_decay=0.01, betas=(0.9, 0.999))
for _ in range(n_steps):
opt.zero_grad(); loss = loss_fn(theta); loss.backward(); opt.step()class AdamW(Adam):
def __init__(self, lr=3e-4, weight_decay=0.01, **kwargs):
super().__init__(lr=lr, **kwargs)
self.weight_decay = weight_decay
def step(self, theta, grad):
theta = theta - self.lr * self.weight_decay * theta # decoupled WD
return super().step(theta, grad) # then plain Adamfrom scratch: lab/solution.py: class AdamW.step (subclasses Adam)
- 1
weight_decay=0.01 in AdamW(...)self.weight_decay stored in __init__ - 2
the decoupled decay torch applies to params before the Adam steptheta = theta - lr * weight_decay * theta, done BEFORE super().step - 3
the underlying Adam moment updatesuper().step(theta, grad) — the inherited Adam.step - 4
decay never entering exp_avg / exp_avg_sqWD is applied straight to theta, not added into grad
What the one call hides
- torch scales the decay by lr exactly like the scratch (theta *= 1 - lr*wd) but folds it into the fused update kernel rather than a separate line.
- Default weight_decay=0.01 is already ON in AdamW — unlike Adam where it is 0 — so 'AdamW' silently regularizes.
- All the Adam internals (betas, eps, bias correction) are still hidden underneath.
- The correctness property — decay NOT passing through the adaptive 1/sqrt(v_hat) scaling — is the whole reason AdamW exists and is invisible in the one-liner.
- Gotcha: People assume Adam(weight_decay=x) == AdamW(weight_decay=x); they are different optimizers (coupled L2 vs decoupled decay) and give different solutions.
- Gotcha: weight_decay in AdamW is multiplied by lr, so changing lr silently changes the effective decay strength.
- Gotcha: Applying the decay AFTER the Adam step instead of before is a common subtle reimplementation bug.
AdamW is the default for transformers and most deep models in production; the 2-line override is the cleanest demonstration of 'decoupled' — decay touches theta directly and skips the moment estimates.
On the job: You set weight_decay and (often) exclude bias/LayerNorm params from it via param groups; the decoupling itself is the library's job.
FIG 04.3.13
Early stopping: regularize by stopping
The simplest A penalty added to the wrongness score that discourages the model from leaning on big, fussy numbers, nudging it to stay simple.Full glossary → technique: train until the validation loss stops decreasing, then stop. Even if the training loss is still going down.
The mechanic: keep a held-out A separate batch of examples you check the model against while you're still tinkering, to see how it's doing.Full glossary →. Every One full trip through every example in your training set.Full glossary → (or every steps), compute the validation loss. Keep a running record of the best validation loss seen so far, and of the model parameters at that point. If you go evaluations without improving, stop and restore the best-known parameters.
from copy import deepcopy
def train_with_early_stopping(model, optimizer, train_loader, val_loader,
max_epochs=100, patience=10):
best_val_loss = float('inf')
best_params = None
epochs_without_improvement = 0
for epoch in range(max_epochs):
# ... train one epoch ...
val_loss = evaluate(model, val_loader)
if val_loss < best_val_loss:
best_val_loss = val_loss
best_params = deepcopy(model.state_dict())
epochs_without_improvement = 0
else:
epochs_without_improvement += 1
if epochs_without_improvement >= patience:
break
model.load_state_dict(best_params)
return modelGéron calls this "a beautiful free lunch" and it is. Watching the model's score on fresh examples and halting training the moment that score stops improving.Full glossary → reduces When a model memorizes the quirks and flukes of its study examples instead of the real pattern, so it flops on anything new.Full glossary →, costs nothing extra to implement, and works for every model class. In sklearn, SGDRegressor(early_stopping=True, n_iter_no_change=10) does this for you.
FIG 04.3.14
The bias-variance decomposition, formally
The expected test error of a model can be decomposed into three components:
A single number that gets added to every guess, the same amount no matter what the inputs are.Full glossary → is how far the average prediction (over many training sets) is from the truth. A model that is too simple has high bias.
Variance is how much the prediction wiggles across different training sets drawn from the same distribution. A model that is too complex has high variance.
Irreducible noise is what you cannot fix by changing the model. It is the noise in the labels.
The trade-off: simpler models reduce variance, increase bias. More complex models reduce bias, increase variance. The optimal model complexity minimizes the sum. A penalty added to the wrongness score that discourages the model from leaning on big, fussy numbers, nudging it to stay simple.Full glossary → is a tool for shifting the trade-off.
FIG 04.4 · Safety lens · this chapter
The training loop you just built is the surface where eval contamination, loss-curve gaming, and shortcut learning all show up. None of these are deep-learning-specific. They start in classical ML and follow the techniques up the abstraction ladder.
Loss-curve gaming. When the reported metric is loss on a held-out set, and the optimization process gets feedback from that metric (through A setting you pick yourself before training starts, like the learning rate, batch size, or number of layers, which the model does not learn on its own.Full glossary → search, model selection, or "I ran Watching the model's score on fresh examples and halting training the moment that score stops improving.Full glossary → with patience=20"), the held-out set is no longer held out. The model is being fit to it indirectly. This is the classical mechanism behind ImageNet test-set leakage and the "we got SOTA after 200 runs" phenomenon. The mitigation is the three-way split: train, validation (used for hyperparameter selection and early stopping), and a sealed A batch of examples you hide away and use only once at the very end to get an honest score.Full glossary → that is touched exactly once at publication time. Geron's recommendation in 08-geron-notebooks/04_training_linear_models §early-stopping is canonical here. See 02-code-refs/amidi-cs229-ml-tips §evaluation and 05-safety/huyenchip-index §evaluation-debt for the production analog.
Shortcut learning. Lasso forces sparsity by zeroing weights. That is great when the zero-weighted features are noise. It is a disaster when the zero-weighted features were proxies for protected attributes you intended to ignore, and the L1 penalty preserved a different proxy you did not catch. The decision surface of a linear model is fully readable from : every nonzero A number inside the model that gets multiplied by an input, deciding how much that input pushes on the final guess.Full glossary → tells you exactly which One piece of information about an example that the model looks at when making a guess.Full glossary → contributes how much. Use this readability. Print your weight vector after every training run. Sort by magnitude. If features that should not matter have large weights, your model is shortcut-learning, regardless of how good the loss is. See 05-safety/anthropic-alignment-index §shortcut-learning for the deep-learning analog and 05-safety/huyenchip-index §fairness-audits for the production methodology.
How well a model's stated confidence matches how often it's actually right.Full glossary → after training. The probabilities your logistic and A function that turns a list of raw scores into confidences for several options that add up to exactly 1 (100%).Full glossary → models output are not real probabilities. They are well-calibrated on the training distribution and uncalibrated everywhere else. For any decision system where you act on being above or below a threshold, you must measure calibration on a held-out set. Reliability diagrams (Guo et al. 2017) are the standard tool. A dial that controls how much a model gambles on unlikely words versus sticking to the most likely one.Full glossary → Rewriting your numbers so that very large measurements and very small ones are put on a comparable footing.Full glossary → (fit a single scalar to rescale logits) fixes most miscalibration for free. The 2024 update is that LLM probabilities are worse calibrated than logistic regression on the same task, because the loss the LLM was trained with weighs in-distribution likelihood and not calibrated A number between 0 and 1 saying how confident the model is, where 1 means totally sure and 0.5 means a coin-flip.Full glossary →. See 22-anthropic-recent calibration papers and 05-safety/aisafetyatlas-index §calibration.
What habits to adopt when you write training code:
- Print the weight vector or the top-10 feature importances after training. If anything looks wrong, your model is wrong.
- Always have a sealed test set you touch once. The A separate batch of examples you check the model against while you're still tinkering, to see how it's doing.Full glossary → is for model selection. The test set is for the report.
- Run your final model on a calibration check. A 5-line
sklearn.calibration.calibration_curvecall catches miscalibrated classifiers in 30 seconds.
FIG 04.5 · Under the hood
The library call, and the lines it hides
You don't have to choose between “use the library” and “build it from scratch.” Here is the one library call, the exact lines it stands in for, and when to reach for which on the job.
RMSProp: torch vs from scratch
DL glueopt = torch.optim.RMSprop([theta], lr=0.01, alpha=0.9, eps=1e-8) # alpha == scratch rho
opt.zero_grad(); loss.backward(); opt.step()class RMSProp(Optimizer):
def __init__(self, lr=0.001, rho=0.9, eps=1e-8):
self.lr, self.rho, self.eps = lr, rho, eps
self.s = None
def step(self, theta, grad):
if self.s is None: self.s = np.zeros_like(theta)
self.s = self.rho * self.s + (1 - self.rho) * grad ** 2
return theta - self.lr * grad / (np.sqrt(self.s) + self.eps)from scratch: lab/solution.py: class RMSProp.step
- 1
alpha=0.9 (smoothing constant)self.rho, the decay on the squared-gradient EMA - 2
the square_avg state bufferself.s = rho*s + (1-rho)*grad**2 - 3
lr=0.01self.lr in the per-parameter scaled step - 4
opt.step() updatetheta - lr * grad / (sqrt(s) + eps) - 5
eps=1e-8 numerical floorself.eps added to sqrt(s) to avoid divide-by-zero
What the one call hides
- torch divides by (sqrt(square_avg) + eps) — matching the scratch — but exposes centered=False (no variance centering) and momentum=0 as extra knobs the one-liner ignores.
- torch's hyperparameter is named alpha (=rho here); torch's default lr (1e-2) also differs from the scratch default (1e-3), so 'just call RMSprop' uses different smoothing/lr unless you set both.
- weight_decay=0 and an optional momentum term are silently available.
- autograd supplies the gradient.
- Gotcha: The smoothing constant is rho here, alpha in torch, rho in Keras — easy to mis-set when porting; you MUST pass alpha=rho AND a matched lr or the 'same output' claim is false.
- Gotcha: RMSProp with no momentum still rattles around the optimum; beginners expect Adam-like smoothness because Adam IS RMSProp plus momentum plus bias correction.
- Gotcha: Without the eps floor, an all-zero initial gradient yields divide-by-zero on step 1.
Rarely the production default now (AdamW usually wins), but torch.optim.RMSprop is one import; the scratch step shows RMSProp is exactly the adaptive-denominator half of Adam.
On the job: You rarely reach for RMSprop by hand at work; when you do, you set lr and alpha to match a reference config and let torch run it.
FIG 04.7 · Chapter notebook
Build this chapter with your own hands
A single self-contained notebook. You implement the ideas, check yourself against assert cells as you go, then finish with a capstone. Hint ladders and folded solutions throughout, so it runs top-to-bottom even before you fill anything in.
What you'll build
- The normal equation solved four independent ways on a y = 4 + 3x problem whose answer you already know, and a machine check that all four agree.
- Batch gradient descent and stochastic gradient descent from scratch, each verified to recover that same closed-form answer.
- A learning rate you blow up on purpose and then repair by reading the loss curve.
- A six-optimizer toolkit (GD, momentum, Nesterov, RMSProp, Adam, AdamW) tested against torch.optim on a fixed problem.
- Logistic and softmax regression from scratch, with their predictions checked against scikit-learn's on iris.
~3 min on CPU · 91 cells · 13 checked exercises · runs in Colab
FIG 04.8 · Going further
01-explorables/distill-momentumGoh's "Why Momentum Really Works". The best single resource on the intuition for why momentum helps on ill-conditioned problems.
08-geron-notebooks/04_training_linear_modelsthe source notebook for almost everything in this chapter. Read in full if you want every figure regenerated yourself.
24-founder-blogs/karpathy-recipe(Karpathy's "A Recipe for Training Neural Networks") — once you understand SGD on linear models, this is the natural next read. Most of the recipe scales down to classical ML.23-textbooks/boyd-convexchapters 3-9 — for the math behind why gradient descent converges, why ridge regression is a quadratic program, and when you should use Newton's method instead.16-d2l-sections/chapter_optimization__sgdand adjacent — D2L has a dedicated optimization chapter that goes deeper on AdaGrad, RMSProp, and the convergence proofs.13-fastbook/16_accel_sgdfast.ai's pragmatic take on optimizer choice for deep learning. Read after this chapter and before another chapter.
03-curricula/google-mlcc-linear-regressionandgoogle-mlcc-logistic-regression— Google's MLCC short-form treatment. Useful for the interactive visualizations.
FIG 04.9 · What this enables
Chapters you can now read, with the connecting idea written out.
SVMs use the same gradient-descent + regularization toolkit, plus a Lagrangian. The L2 penalty geometry from this chapter is directly the max-margin geometry.
Gradient boosting is gradient descent in function space. Knowing GD on a finite-dimensional parameter vector is the prerequisite for knowing it on an infinite-dimensional function space.
Every neural network trains by some variant of mini-batch SGD + AdamW with weight decay. This chapter is the gentle case where you can verify against a closed form.
Bias-variance and the train/val/test split are the foundations of every eval-design question downstream.
FIG 04.10 · 22 sources
- 01-explorables/distill-momentum
- 02-code-refs/amidi-cs229-supervised
- 02-code-refs/amidi-cs229-ml-tips
- 03-curricula/google-mlcc-linear-regression
- 03-curricula/google-mlcc-logistic-regression
- 04-stanford/cs229-main-notes-pdf
- 05-safety/anthropic-alignment-index
- 05-safety/huyenchip-index
- 05-safety/aisafetyatlas-index
- 08-geron-notebooks/04_training_linear_models
- 13-fastbook/04_mnist_basics
- 13-fastbook/16_accel_sgd
- 16-d2l-sections/chapter_linear-regression__linear-regression
- 16-d2l-sections/chapter_linear-regression__linear-regression-scratch
- 16-d2l-sections/chapter_linear-regression__linear-regression-concise
- 16-d2l-sections/chapter_linear-regression__generalization
- 16-d2l-sections/chapter_linear-regression__weight-decay
- 16-d2l-sections/chapter_linear-classification__softmax-regression
- 16-d2l-sections/chapter_linear-classification__softmax-regression-scratch
- 22-anthropic-recent (calibration)
- 23-textbooks/boyd-convex
- 24-founder-blogs/karpathy-recipe