Key takeaways
- A neural network is logistic regression with one or more layers wedged in between, plus a nonlinearity so those layers cannot collapse back into a single one.
- Backpropagation is the chain rule applied backwards through that stack. Thirty lines of numpy is enough to see the whole thing.
- Forgetting to standardise features dropped test average precision from 0.5445 to 0.1062 on my churn matrix, and accuracy barely moved, so nothing warned me.
- Best network scored 0.5445 against 0.5341 for gradient boosting, at seven times the fit time, 11,009 parameters and a hard requirement for scaling and imputation.
- On tabular churn data, boosting stays my default. A network earns its place when features stop being tabular.
Nothing about a neural network is new to you. You already fitted one in Part 10. Logistic regression takes a weighted sum of your features, adds a bias, squashes the result through a sigmoid, and reports a probability. That is a neural network with exactly zero hidden layers, and every piece of vocabulary that gets thrown at deep learning maps onto something in it. Weights are the coefficients. Bias is the intercept. Activation is the sigmoid. Loss is log loss. Training is gradient descent.
Everything else in this part is one idea repeated: put a layer in the middle, make it nonlinear, and push gradients back through it. Once that clicks, PyTorch in Part 17 becomes a matter of syntax rather than concepts, which is exactly how it should feel.
Logistic regression as a network with no hidden layer
Where our project stands: last part we tuned a gradient boosting model on the churn feature matrix and landed on an honest test average precision after accounting for search optimism. This part keeps that exact matrix and asks a different question, which is whether a network can beat it, and what the network costs to run.
I am modelling churn on a 20,000 row matrix with a 3.4 percent positive rate, standing in for the public Telco Customer Churn file this series works from. Average precision remains the scoring metric, for the reasons argued in Part 14. Here is our baseline, and notice the shape of the fitted coefficients.
Twenty weights, one bias, average precision of 0.3880. Write it as a matrix product and the network reading falls out immediately: a 20 by 1 weight matrix maps our 20 inputs to a single output unit, a bias is added, a sigmoid squashes it. Deep learning papers would call that a single dense layer with sigmoid activation. Same object, different vocabulary.
Gains from adding a hidden layer
Our logistic model can only draw a straight boundary through feature space. Customers who churn when tenure is short and charges are high, but not when either condition holds alone, sit outside what a weighted sum can express. In Part 6 we solved that by hand, building interaction terms one at a time. A hidden layer does the same job, except it learns which interactions matter instead of waiting for you to guess.
Insert a layer of 16 units between input and output and you get a 20 by 16 matrix, then a 16 by 1 matrix. Multiply those two together, though, and you land back at a single 20 by 1 matrix. Stacking linear layers gains nothing at all. Nonlinearity between them is what stops the collapse, and ReLU, which is just max of zero and the input, is the one almost everybody reaches for because its gradient is either zero or one and never vanishes on the positive side.
Sigmoid still sits at the output because we want a probability between zero and one. Hidden layers use ReLU because they are computing intermediate representations, not probabilities, and squashing those into a narrow range throttles the gradient. Mixing the two up is a common beginner error and produces a network that trains agonisingly slowly for no visible reason.
Backpropagation in thirty lines of numpy
Backpropagation gets described as if it were an algorithm you need to study. It is the chain rule, computed once per layer, moving right to left. Write it out for a two layer network and the entire thing fits in a screen. Below is a complete trainable network with no framework anywhere in it.
Read the backward block once more, slowly, because three lines carry all the meaning. Line one, dZ2 is the prediction minus the truth, which is the derivative of log loss composed with sigmoid, and it comes out that clean only because those two are paired. Line two, dA1 pushes that error back through W2 by transposing it. Line three, dZ1 masks the error wherever the ReLU output was zero, since a dead unit contributed nothing and therefore deserves no gradient. Every deep learning framework does exactly this, generalised and on a GPU.
Do not miss the last line of output. Loss dropped by 84 percent and test average precision came in at 0.3461, which is worse than the 0.3880 logistic regression managed in one second with twenty parameters. Falling loss is not the same as a better model, particularly on an imbalanced target where a network can drive loss down by getting the easy negatives even more confidently right. Plain gradient descent with a fixed learning rate and no minibatching is also simply a weak optimiser, which is what Adam fixes.
Feature scaling, and a four times collapse in average precision
Real churn tables have columns on wildly different scales. Tenure runs zero to 72 months. Monthly charges run about 18 to 118. Total charges run zero to 8,000 or so. Tree models could not care less, since they only compare a value against a threshold. Networks care enormously, because a gradient step of a fixed size means something completely different for a column with a standard deviation of 3,177 than for one with a standard deviation of 1.
To measure it, I rescaled three of the twenty columns to those realistic ranges and fitted the same MLPClassifier twice, once bare and once inside a pipeline with StandardScaler in front of it.
War story
I lost most of a morning to precisely this. A colleague had refactored our churn training script and moved the scaler out of the pipeline into a preprocessing step that quietly stopped running for the network branch. Accuracy on the dashboard went from 0.9758 to 0.9660, which nobody flagged, because on a 3.4 percent positive rate that is a rounding error and both numbers sit above the 0.9656 you get by predicting that nobody ever churns. Retention noticed a week later, when their call list stopped converting. Average precision had gone from 0.5445 to 0.1062. Now I check that a scaler is actually inside the estimator object with a one line assertion in the test suite, and I never report accuracy on an imbalanced target again.
Two failures wait for you here and both are worth meeting on purpose. First, missing values. Gradient boosting in scikit-learn handles NaN natively, so a column you never imputed passes silently. Networks refuse outright.
ConvergenceWarning is the dangerous one, because a warning does not stop a script and a half trained network still returns predictions that look plausible. Treat it as an error in any pipeline that runs unattended. Handling of missing values, incidentally, is the same discipline covered in cleaning messy data, and moving from trees to networks removes your ability to skip it.
Initialisation, and units that die before they learn
One line in the numpy code above deserves more attention than it usually gets. Weights were drawn from a normal distribution scaled by the square root of two over the number of inputs, which is He initialisation, named for Kaiming He and his co authors who proposed it for exactly this pairing of ReLU with deep stacks. Set every weight to zero instead and the network never trains at all, because all 16 hidden units compute the identical value, receive the identical gradient, and stay identical forever. Symmetry has to be broken before anything can be learned.
Draw weights too large and you get a subtler failure. A hidden unit whose pre activation lands well below zero for every single row in your training set outputs zero for every row, and because the ReLU derivative is zero there, it receives no gradient and cannot recover. That unit is dead for the rest of training. Capacity you paid for and cannot use. Scaling initialisation by fan in keeps early pre activations centred near zero, which keeps roughly half the units firing and all of them reachable.
Diagnosing this takes one line. After a few epochs, compute the fraction of hidden activations that are exactly zero across a batch. Around 40 to 60 percent is healthy sparsity. Anything above about 90 percent means most of your layer has died and no amount of extra epochs will revive it; drop the learning rate or reinitialise. I have seen a network sit at 0.5 average precision for an hour of training because 118 of its 128 hidden units were producing zeros, and the loss curve gave no hint whatsoever, since the surviving ten units were quite happily reducing it. Frameworks default to sensible initialisation now, which is why this is rarely a problem you cause deliberately, but writing your own layer in Part 17 puts it back within reach.
Sizing a network for tabular data
Choosing hidden layer sizes attracts more superstition than any other decision in modelling. I swept five architectures on the identical split, holding everything else fixed, and the results argue against most of the folklore.
| hidden_layer_sizes | Parameters | Iterations | Test AP | Fit time |
|---|---|---|---|---|
| (8,) | 177 | 68 | 0.4992 | 0.8 s |
| (32,) | 705 | 42 | 0.5279 | 0.7 s |
| (32, 16) | 1,217 | 27 | 0.5033 | 0.6 s |
| (128, 64) | 11,009 | 31 | 0.5445 | 2.2 s |
| (256, 128, 64) | 46,593 | 20 | 0.5290 | 3.8 s |
Three things stand out. Going from 177 parameters to 11,009 bought 0.0453 average precision, which is real. Going from 11,009 to 46,593 lost 0.0155 while taking 73 percent longer, so depth on a twenty column table is a straightforward waste. And a single wide layer at (32,) beat the deeper (32, 16) outright, which surprises people who assume more layers means more capacity. Twenty features simply do not contain enough structure to justify a hierarchy.
My starting point for tabular work is one hidden layer sized between two and eight times the feature count, early stopping switched on, and Adam left at its defaults. Search width before you search depth. Reach for a second layer only when a wider single layer has clearly plateaued, which on churn data it usually has by around 128 units.
Reading a loss curve without fooling yourself
MLPClassifier exposes loss_curve_ after fitting, and with early_stopping switched on it also exposes validation_scores_. Plot both together. Doing so on our (32, 16) run produced the single most instructive chart in this part.
Look at what those two panels say together. Training loss halved between epoch 16 and epoch 58. Validation accuracy over the same stretch wobbled between 0.9760 and 0.9787 and ended lower than its peak. All that late training bought nothing; it was memorisation. Early stopping caught it, which is exactly why I switch it on by default rather than tuning max_iter by hand.
Note also that scikit-learn scores validation with accuracy, not with your chosen metric, and on a 3.4 percent positive rate that makes early stopping considerably less sensitive than you would like. Best validation accuracy of 0.9787 sits 1.3 points above predicting no churn for everybody. If you want early stopping driven by average precision, you have to leave MLPClassifier and write your own loop, which is one of the better arguments for moving to PyTorch in Part 17.
Network against gradient boosting on the same churn split
Here is the comparison that decides whether any of this ships. Four models, one split, one metric.
| Model | Test AP | Accuracy | Needs scaling | Handles NaN |
|---|---|---|---|---|
| Logistic regression | 0.3880 | 0.9706 | Yes | No |
| MLP (128, 64), unscaled | 0.1062 | 0.9660 | Yes | No |
| MLP (128, 64), scaled | 0.5445 | 0.9758 | Yes | No |
| HistGradientBoosting | 0.5341 | 0.9746 | No | Yes |
My verdict, and it is not the exciting one. Gradient boosting is what I would put in front of a stakeholder for churn on a tabular feature matrix. It cost 0.3 seconds against 2.3, needed no scaler, swallowed missing values without complaint, and gave up 0.0104 average precision, which is inside the fold to fold noise we measured in Part 15. Every operational property favours the tree model, and operational properties are what break at three in the morning. What I would avoid is the unscaled network, obviously, and equally the three layer architecture, which cost 73 percent more compute to score worse than a single wide layer.
My take
Learn networks properly even though boosting will beat them on your churn table. Two reasons. First, everything downstream in this series, meaning transformers in Part 18 and sequence models in Part 19, is this same machinery with a different layer type, so the investment compounds. Second, the moment your churn features stop being twenty numeric columns and start including support ticket text, clickstream sequences or learned customer embeddings, trees have nothing to offer and a network is the only thing that can consume them end to end.
Start with boosting, add a network when features stop being tabular
Concretely, for our churn project: keep the tuned HistGradientBoostingClassifier from Part 15 as the production model. Keep the scaled MLP as a benchmark you re run whenever the feature set changes materially, because the day it pulls decisively ahead is a real signal that your features have grown structure a tree cannot see. Do not ship a network that wins by 0.0104 average precision. Do ship one that wins by 0.05.
And remember what the numbers in this part actually taught, which was less about architecture than about measurement. Accuracy separated our four models by one percentage point while average precision separated them by a factor of five. A scaler going missing was invisible on one metric and catastrophic on the other. Choose your metric before you choose your model, a habit that goes all the way back to Part 11.
Part 17 takes the thirty line numpy loop from this part and rewrites it in PyTorch, where autograd computes those three backward lines for you and a readable training loop replaces the fixed step gradient descent we used here. Before you move on, run the loss curve plot on your own model and put the majority class accuracy on the same axis as a dashed line. If your validation curve sits close to that line, your network has learned nothing worth shipping, and you will know it in thirty seconds rather than after a retention campaign.
References
- scikit-learn API reference, MLPClassifier, for hidden_layer_sizes defaulting to (100,), solver defaulting to adam, and the loss_curve_ and validation_scores_ attributes, verified against scikit-learn 1.7.2
- scikit-learn user guide, Neural network models (supervised), which states plainly that multi layer perceptron is sensitive to feature scaling and recommends StandardScaler
- scikit-learn API reference, StandardScaler, for the fit on train and transform on test contract that keeps scaling out of your leakage surface
- NumPy reference, random Generator, for default_rng, the modern replacement for np.random.seed, verified against numpy 2.2.6
- Telco Customer Churn dataset, the public IBM sample file this series works from


DrJha