Machine learning, L2 — Perceptron and Classifier#
Level 2 · Practitioner
Source: Perceptron.hb ·
Classifier.hb
Two models, and the most instructive thing on this page is a failure.
A model has weights, and a fn does not#
Line.hb held functions — data in, answer out, nothing remembered. Perceptron.hb holds a
model:
model Perceptron(inputs, hidden, seed) : Learner {
weight first : Tensor[inputs, hidden] from gaussian(seed) · 0.5
weight second : Tensor[hidden, 1] from gaussian(seed) · 0.5
def train(x, y, rate) {
first ≔ stepFirst(rate, x, y, first, second)
second ≔ stepSecond(rate, x, y, first, second)
}
def eval(x) ≔ outputs(x, first, second)
}
The constructor takes the architecture; train takes the data. The weights are fields on one
object, visible between steps as First and Second, so a caller can step the network, watch it,
and ask it anything at any point in the fit. That is the whole difference between a model and a
formula that happens to fit.
One gradient step per call#
stepFirst and stepSecond each compute one step of gradient descent, and both read the weights
this call was entered with — so the pair is one step on both layers, not a step on the first layer
followed by a step computed from the already-changed one.
The scale on the draw is not decoration#
That · 0.5 matters. Start a rectified network from a full standard normal, step it at any useful
rate, and it saturates on the first turns: every hidden unit dies and the network answers exactly
zero forever. It looks like training did nothing. It is really the network was dead before it
started. A test asserts a fresh network is not.
Watch the error fall#
XOR is the fixture — the smallest problem one layer cannot solve and two can.
The test asserts only that the loss fell by half, not that it reached 10⁻³¹. The second would be a claim about seed 7 on four points, and nobody can defend a number like that. "The error fell" is what training means.
A class is one more index#
Classifier.hb is the Perceptron with one change:
The last layer is classes wide instead of 1. That is all a class is. Most courses present
classification as a different subject from regression; it is the same network with a wider last
layer, and targets written one column per class.
The softmax lives at the call site#
eval answers scores; shares answers a distribution. Baking the softmax into the network
would make every caller pay for it and make the scores unreachable — which matters, because the
scores are what you train on.
And nothing is lost by keeping it outside, because the softmax is monotone: it never changes which class wins, only how sure the answer sounds. A test asserts exactly that, row by row.
The failure worth understanding#
Written as two classes, XOR needs this at the origin:
Train the classifier on the plain inputs, and at (0, 0) it answers:
Zero for both classes, and no amount of training moves it.
Here is why. These networks have no bias term — and that is not a bug, it is the library's
stated convention. Prelude/Networks.hb says so in its first paragraph:
"A BIAS IS A COLUMN OF ONES IN THE DESIGN, exactly as it is for
Prelude/Multivariate.hb's least squares — so no layer here carries one, and a caller who wants a bias augmentsx."
This project found that out the slow way, by a failing test, when reading the module header would have said it in one sentence. Worth the lesson on its own: read the header of the module you are spending before debugging what it did.
So every unit computes weights × input and nothing else. At the input (0, 0), that product is
zero whatever the weights are — so the network answers zero at the origin, always, and a gradient
step changes the weights without changing a product of zero.
The fix is the column of ones from L1:
A constant input, weighted, is a bias. It is exactly how Line got its intercept, and it turns out a
neural network needs it for exactly the same reason.
And an honest note about the Perceptron#
This means one of the Perceptron's four XOR answers was never learned. Its target at (0, 0) is
0 — which the bias-free network outputs anyway. The other three rows are genuinely learned; that
one came free. The test file says so, because a result that is partly luck should say which part.
A bad start is not rescued by more training#
With the bias column in place, try four seeds:
| Seed | Loss after 500 steps | Loss after 3000 | Every row right |
|---|---|---|---|
| 1 | 3 × 10⁻²⁶ | 3 × 10⁻³¹ | yes |
| 2 | 3 × 10⁻²⁰ | 5 × 10⁻³¹ | yes |
| 3 | 9 × 10⁻²⁸ | 3 × 10⁻³³ | yes |
| 7 | 0.25 | 0.25 | no |
Seed 7 does not creep slowly. It stops dead: the weights after 3000 steps are bit-for-bit the weights after 500. That is not a plateau — a plateau has a small slope and training inches across it. This has no slope at all, and the reason is a well-known failure called dying ReLU.
A rectified unit outputs max(0, z). If z is negative for every example, the unit outputs zero
and passes back a zero gradient — so it never learns its way out. Look inside seed 7 after
training:
| Seed 7 | Seed 1 | |
|---|---|---|
| Dead hidden units | 5 of 8 | 2 of 8 |
| Weight change, step 500 → 1500 | exactly 0 | still moving |
By step 500, five of seed 7's eight units are dead — measured after training, so some may have died
along the way rather than at the draw. The three survivors fire in patterns that cannot tell row 0
(0,0,1) from row 2 (1,0,1) — and those two rows want opposite answers. So the network settles
on the compromise between them, where their gradients cancel exactly, and there is nothing left for
descent to follow.
Seed 1 keeps six units alive, firing in enough different patterns to separate every row.
This is gradient descent behaving correctly, not a flaw in Hilbert: seed 1 reaching 10⁻³¹ is the evidence that the gradients are right.
That is why real training restarts from several seeds and keeps the best, and why "the loss stopped falling" is not the same claim as "the model is as good as it gets".
The tests do not quietly switch to a seed that passes. They use seed 1, say why, and pin seed 7's stuck state as a test of its own — asserting the weights do not move by a single bit. Choosing a passing seed without saying so would be cherry-picking, and a reader who later tried seed 7 would find out the hard way.
Run them#
dotnet run --project src/MLambda.AI.ML -- Perceptron
dotnet run --project src/MLambda.AI.ML -- Classifier
What the tests assert#
PerceptronTests.cs ·
ClassifierTests.cs
- A fresh network is not dead on arrival.
- Training reduces the error; one call is one step and moves the weights.
- The same seed draws the same network — and a different seed draws a different one, which is what stops the first test passing for a network that ignored its seed.
- Every row of shares is a distribution, and the softmax never changes which class wins.
- Once trained, the classifier picks the right class for every row.
- A bias-free network is stuck at zero on the origin.
- A bad start is not rescued by more training.
Try it yourself#
- Remove the
· 0.5from the Perceptron's weights and watch whether the network survives. - Train the classifier from seeds 4 through 10. How many get stuck?
- Give the classifier three classes and a fixture that needs all three.