Week 14 — Neural Nets, Without the Mystique¶
Course: Applied ML Foundations for SaaS Analytics
Who this is for: Engineers who keep hearing “just use a network.” This week is permission to not, plus a picture of what a net actually is.
We will not pretend we taught calculus-level backpropagation.
🎯 What you will be able to do¶
- Draw a net as an assembly line of mixers + on/off switches
- See why stacked linear layers collapse to one linear layer (so we need activations)
- Regularize with dropout + early stopping, and read a train/val loss curve
- Write the four-line PyTorch training step: zero_grad → forward → backward → step
- Decide GBDT vs net on a tabular SaaS problem honestly
Think of it like… an assembly line of mixers.
Each hidden layer takes the previous numbers, mixes them (weighted sum), then puts each mix through a cheap non-linear switch (ReLU: “if negative, make it 0”). The last mixer outputs a churn score. Training is credit assignment: nudge every weight a tiny bit so tomorrow’s score is less wrong. The library does the calculus (backprop). You do the architecture and the data.
If you already write software¶
A neural net is not magic and not a brain. It is mixers + switches, stacked.
input numbers
│
▼
linear mix (weighted sum — the same idea as a spreadsheet SUMPRODUCT)
│
▼
switch (ReLU: if negative, emit 0; else pass through)
│
▼
linear mix
│
▼
switch
│
▼
one number (a churn score)
Without the switch, stacked mixers collapse into one mixer — you paid for depth and got a linear model. ReLU is the cheap non-linearity that makes depth mean something.
Why a GBT still wins on this table¶
CloudWave’s scoring row is 7 columns (FEATURE_COLS). A gradient-boosted tree will usually beat a small net here, train faster, and be easier to ship. You are learning nets this week so weeks 18–20 (usage-over-time, sequences, text) make sense — not because a net is the right churn model.
Picture the training loop¶
for each batch of customers:
optimizer.zero_grad() # do not accumulate last batch's blame
score = model(features) # forward
loss = how wrong is the score # the complaint
loss.backward() # fill in .grad on every weight
optimizer.step() # nudge weights to be less wrong
That is the whole mystery. The library does the calculus (backprop). You pick the architecture, the data, the loss, and when to stop.
Laptop budget
No GPU. Aimed at ~8 GB RAM. Training uses a few thousand sampled customers (or short sequences) so this week should finish in a few minutes on CPU. The ideas are the same if you later set n=None and train on all ~49k rows.
import numpy as np
import matplotlib.pyplot as plt
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.neural_network import MLPClassifier
from pipelines.features import AS_OF_DEFAULT, FEATURE_COLS, build_features, make_preprocessor
from pipelines.labels import drop_unlabelled, label_eventual_churn
Why activations exist — a 30-second proof¶
If every layer is just x → Wx + b, then two layers are W2(W1x + b1) + b2, which is some other W'x + b'. You built a logistic regression with extra typing.
ReLU (and friends) break that collapse. That is the whole reason they exist.
features ─► mix ─► ReLU ─► mix ─► ReLU ─► mix ─► sigmoid ─► score
▲ ▲
└── without these switches, the whole tower is one line
Math, translated — backprop in one sentence
After a batch of examples, we know how wrong the score was. Backprop walks backward through the assembly line and estimates “if I wiggle this weight, does the error go up or down?” Then we wiggle it the helpful way (gradient descent). You will not derive it this week. You will treat it like the compiler: necessary, already written.
# Collapse demo: two linear maps == one linear map
rng = np.random.default_rng(0)
X = rng.normal(size=(5, 3))
W1, b1 = rng.normal(size=(3, 4)), rng.normal(size=4)
W2, b2 = rng.normal(size=(4, 2)), rng.normal(size=2)
two_layers = (X @ W1 + b1) @ W2 + b2
W_eq, b_eq = W1 @ W2, b1 @ W2 + b2
one_layer = X @ W_eq + b_eq
print("Max difference between 2 linear layers and 1 equivalent layer:",
np.max(np.abs(two_layers - one_layer)))
print("That number should be ~0. Activations are what make depth real.")
CloudWave bake-off — sklearn MLP vs logistic vs GBT¶
MLPClassifier is the sklearn stand-in. Real dropout lives in PyTorch (nn.Dropout) below. sklearn’s alpha is L2, not dropout.
df = build_features(as_of=AS_OF_DEFAULT, n=None, at_risk_only=True)
y = label_eventual_churn(df, AS_OF_DEFAULT)
df, y = drop_unlabelled(df, y)
cut = df["signup_date"].quantile(0.80)
train, test = df[df["signup_date"] <= cut], df[df["signup_date"] > cut]
y_train, y_test = y.loc[train.index], y.loc[test.index]
prep = make_preprocessor()
X_train_t = prep.fit_transform(train[FEATURE_COLS])
X_test_t = prep.transform(test[FEATURE_COLS])
for name, model in [
("logreg", LogisticRegression(max_iter=1000)),
("gbt", GradientBoostingClassifier(n_estimators=40, max_depth=2, random_state=42)),
("mlp", MLPClassifier(hidden_layer_sizes=(16, 8), activation="relu",
max_iter=20, random_state=42)),
("mlp+L2", MLPClassifier(hidden_layer_sizes=(16, 8), activation="relu",
alpha=0.01, max_iter=20, random_state=42)),
]:
model.fit(X_train_t, y_train)
auc = roc_auc_score(y_test, model.predict_proba(X_test_t)[:, 1])
print(f"{name:<18} AUC={auc:.3f}")
The one plot a net owes you: train vs validation loss¶
If train loss keeps falling and val loss turns up, you are memorizing. Early stopping = take the checkpoint when val was best. sklearn’s MLP gives you loss_curve_ (log-loss). Do not plot it against 1 − accuracy — different units, different story. Dropout is “randomly break mixers”; it is the nn.Dropout layer in the PyTorch net below, not alpha.
mlp = MLPClassifier(hidden_layer_sizes=(24, 12), activation="relu",
max_iter=25, random_state=42, early_stopping=True,
validation_fraction=0.2, n_iter_no_change=5)
mlp.fit(X_train_t, y_train)
fig, ax = plt.subplots(figsize=(8, 3.4))
ax.plot(mlp.loss_curve_, label="train log-loss", color="#1d4ed8")
ax.set_xlabel("epoch (one pass over the data)")
ax.set_title("Learning curve — stop when this stops helping (early_stopping=True already did)")
ax.legend()
plt.tight_layout()
plt.show()
print("Test AUC (early-stopped MLP):",
f"{roc_auc_score(y_test, mlp.predict_proba(X_test_t)[:, 1]):.3f}")
print("On this table, GBT is usually equal or better. That is the lesson.")
PyTorch — NumPy with a tape recorder¶
Week 0 promised this. A torch.tensor is a NumPy array that remembers the recipe. loss.backward() walks the recipe and fills .grad on every weight. optimizer.step() nudges the weights the helpful way.
batch of rows
│ zero_grad → forward
▼
logits → loss
│ backward (the library’s calculus)
▼
.grad on every weight
│ step
▼
slightly less-wrong weights
Install once: pip install torch (CPU is enough for this course).
try:
import torch
import torch.nn as nn
except ImportError:
raise SystemExit("PyTorch is missing. Install with: pip install torch") from None
torch.manual_seed(0)
x = torch.tensor([2.0, 3.0], requires_grad=True)
y = (x ** 2).sum() # 4 + 9 = 13
y.backward()
print("x ", x.tolist())
print("y ", float(y))
print("x.grad ", x.grad.tolist(), " ← d(x1²+x2²)/dx = 2x")
# Same CloudWave table, now as tensors
Xt = torch.tensor(np.asarray(X_train_t, dtype=np.float32))
yt = torch.tensor(y_train.to_numpy(), dtype=torch.float32).unsqueeze(1)
Xv = torch.tensor(np.asarray(X_test_t, dtype=np.float32))
net = nn.Sequential(
nn.Linear(Xt.shape[1], 16),
nn.ReLU(),
nn.Dropout(0.2), # the actual dropout; off later with net.eval()
nn.Linear(16, 1), # one logit; sigmoid lives in the loss
)
opt = torch.optim.Adam(net.parameters(), lr=1e-2)
loss_fn = nn.BCEWithLogitsLoss()
train_losses, val_aucs = [], []
for epoch in range(12):
net.train()
opt.zero_grad()
logits = net(Xt)
loss = loss_fn(logits, yt)
loss.backward()
opt.step()
train_losses.append(float(loss))
net.eval()
with torch.no_grad():
scores = torch.sigmoid(net(Xv)).numpy().ravel()
val_aucs.append(roc_auc_score(y_test, scores))
fig, axes = plt.subplots(1, 2, figsize=(10, 3.4))
axes[0].plot(train_losses, color="#1d4ed8")
axes[0].set_title("PyTorch train loss")
axes[0].set_xlabel("epoch")
axes[1].plot(val_aucs, color="#0f766e")
axes[1].set_title("Holdout AUC while we train")
axes[1].set_xlabel("epoch")
plt.tight_layout()
plt.show()
print(f"Final holdout AUC: {val_aucs[-1]:.3f}")
print("Remember the four calls: zero_grad → forward → backward → step.")
Watch out
Forget optimizer.zero_grad() and gradients pile up — the model walks off a cliff. Forget model.eval() later and dropout will stay on at serve time. A 32-16-8 net on 5 columns is still theatre: depth does not invent information that is not in the features.
Ship / don’t ship
Tabular SaaS, < ~100k rows, mixed columns → gradient-boosted trees.
Images, text, audio, long sequences → deep learning.
A net is not “more serious.” It is a different tool. Pick the one you can monitor at 3 a.m.
✍️ Exercise¶
When you can explain the week out loud, do the exercises. Starter: python exercises/ml/week-14/starter.py from the repo root.
🤔 Reflection¶
- What problem is the activation function solving, in your own words?
- Who owns backprop on your team — you, or the library? What do you still own?
- Name one product surface at CloudWave where a net would be the right call (e.g. search ranking on ticket text).
- In one sentence: what does
loss.backward()put on each weight?
🔗 Next week¶
The pickle: time split, predict(), a versioned artifact. After that: the job pipeline, then (optionally) CNNs, RNNs, and Transformers.