The Machines · Statistics & machine learning
Feed-Forward Neural Networks — machines/neural.shoddy
neural gives Shoddy the classic small neural network — a
program that learns a pattern from examples by adjusting stored numbers
called weights. This one has one hidden layer, a middle rank
of Tanh units between input and output. It is small enough to
hold entirely in your head. It trains by mini-batch stochastic gradient
descent: take a small shuffled batch of examples, measure the error, and
nudge every weight a little downhill. A Net is a plain record of
four fields: two weight matrices and two bias vectors. The design's one big
win is that a gradient has the same shape as the network. A
gradient is the set of nudges, one per weight, so the very same record
represents both. Accumulating, averaging, and applying an update all fall out
of two words — NetAdd and NetScale. No zeroing
loops, no mutable scratch arrays: training is a pure Fold over
epochs (full passes through the data), and each epoch is a Fold
over shuffled mini-batches. The same identity carries momentum, because a
velocity is a Net too.
It does two jobs, chosen by a head. Regress
reads the output layer as-is and fits squared error — predict a number, score
it on how close. Classify reads it through Softmax,
which turns raw scores into probabilities that sum to 1, and fits
cross-entropy — predict a category, score it on how often it's right, and get
a probability for every candidate. Those look like different networks in most
textbooks. Here they share one backward pass, for a reason worth knowing
(see Under the Hood).
The idea of a network of simple threshold units goes back to McCulloch and Pitts in 1943. Rosenblatt's perceptron of 1958 could even learn — but only a single layer of it. Minsky and Papert's 1969 book Perceptrons proved crisply how little a single layer can do. What was missing for the next fifteen years was not the idea of hidden layers but a way to train them: if the output is wrong, how much of the blame belongs to a weight buried two layers deep? The answer is to apply the chain rule — the calculus rule for tracing a change through nested steps — sweeping the error backwards through the same connections the signal came forward through. That answer was found several times: by Werbos in 1974, and famously by Rumelhart, Hinton, and Williams in 1986, who named it backpropagation and showed the world it worked. Every modern deep-learning framework, however vast, is still doing exactly this. A forward pass remembers its activations — each unit's computed output. A backward pass turns one subtraction at the output into a gradient for every weight. The weights then take a small step downhill. This machine implements that 1986 algorithm at its original scale, where you can watch every part of it happen.
Sometimes the relationship you want to learn from data isn't a line, and
no amount of squinting at stats' LinFit
will make it one. A one-hidden-layer network is the smallest honest tool for
that job. Given enough hidden neurons it can approximate any reasonable
function. With a few hundred rows and a few thousand epochs, it does so on
classroom hardware in classroom time. Reach for neural when you
have a table of numeric inputs and either a numeric target whose mapping
bends, or a label drawn from a short fixed list. It builds on
matrix: the forward
pass is a MatVec, and the weight gradients are Outer
products. It also uses random (each epoch shuffles),
file (weights save and load as plain text), and
recio (the binary model format underneath
NetSaveBin/NetLoadBin).
Freq, nearest neighbour, plain
LinFit. This machine is here for when the mapping genuinely
bends. It is also the honest small implementation of an algorithm worth
understanding from the inside.Rnd, the random-number source. The constructors
(NetNew, NetNewScaled) draw the initial weights,
and the training loops (NetTrain, NetFit) shuffle
each epoch. If a program wants reproducible runs, it calls the
Seed builtin once, at the top of Main — the same
edge random.shoddy documents.Build a net, train it, ask it questions. The training data is a
List of input rows (each an Array Of Number) and a
List of target numbers.
Include "neural.shoddy"
Def Main()
Seed(0) ' reproducible runs — do this first
Let xs = { ToArray({ 0.1 }), ToArray({ 0.5 }), ToArray({ 0.9 }) }
Let ys = { 1.2, 2, 2.8 } ' y = 2x + 1
Let n0 = NetNew(1, 4, 1) ' 1 input, 4 hidden, 1 output
Let n = NetTrain(n0, xs, ys, 0.1, 3, 500, [ Drop Drop ]) ' silent report
Print(NetOut(n, ToArray({ 0.7 }))) ' close to 2.4
NetSave("wts.txt", n)
Let m = NetLoad("wts.txt", 1, 4, 1) ' same predictions to ~1e-10
Classification is the same machine wearing the other head.
Targets become one-hot vectors — all zeros, with a single 1 marking the
class — instead of numbers. The hyperparameters (the training settings: rate,
momentum, batch, epochs) travel as a Plan, and the answer comes
back as a probability for each
class:
Include "neural.shoddy"
Def Main()
Seed(0)
Let raw = { ToArray({ 5.1, 3.5 }), ToArray({ 6.7, 3.0 }), ToArray({ 5.0, 3.4 }) }
Let ys = { NetOneHot(1, 2), NetOneHot(2, 2), NetOneHot(1, 2) } ' 2 classes
Rem centre and scale the inputs, and keep the scaler — you will need
Rem exactly this transformation again at prediction time
Let sc = ScalerFit(raw)
Let xs = Map(raw, Fn(x) => ScalerApply(sc, x))
Rem ClassPlan(rate, momentum, batch, epochs)
Let n = NetFit(NetNewScaled(2, 6, 2), ClassPlan(0.1, 0.9, 3, 300), xs, ys, [ Drop Drop ])
Print(NetClassAccuracy(n, xs, ys)) ' 1
Print(NetPredict(n, Classify(), First(xs))) ' probabilities, summing to 1
Rem save weights + head + scaler as one artifact, then predict from RAW input
ModelSave("model.bin", Model(n, Classify(), sc))
Let m = ModelLoad("model.bin")
Print(ModelClassOf(m, ToArray({ 5.1, 3.5 }))) ' 1 — no scaling by hand
A few things worth remembering:
NetNew and NetTrain's shuffle are the machine's only
impure words. One Seed(k) at the top of Main pins
them both.[ Drop Drop ] is the
silent report. The pattern If epoch Mod freq = 0 Then ... prints
every freq-th epoch and costs nothing in between.Floor(n / batSize) consecutive chunks. The
remainder rows (fewer than a full batch) are dropped that epoch — a different
remainder each time, since the shuffle is fresh.ScalerFit centre each column on its mean and divide by its
spread. Either way, the prediction side must apply the identical
transformation. That is why Model stores the scaler beside the
weights and ModelPredict takes raw inputs. Rebuilding the
transformation by hand in a second program is the classic way to end up
serving a model inputs it was never fitted on, with nothing to warn you.NetFit's Plan carries a momentum coefficient. 0.9 is
the conventional starting point, and it typically reaches a given loss in a
fraction of the epochs plain descent needs. Setting it to 0 reduces
NetFit to exactly the descent NetTrain performs —
bit for bit, which the machine's tests assert.NetSave writes text, one number per line, in
the traditional order: input-hidden weights input-major, hidden biases,
hidden-output weights hidden-major, output biases. Weight files therefore
interchange with the classic C# demo (Str writes 10 significant
digits, so a text round trip reproduces predictions to about 1e-10 relative).
NetSaveBin writes the same sequence as raw 8-byte doubles behind
a self-describing header. So NetLoadBin(path) needs no
architecture arguments, and the round trip is bit-exact.The mills use this machine end to end, and reading iris and
demographics side by side is the fastest way to see the difference between the heads.
iris predicts a category (species) with the
Classify head. It is scored on how often it is right, and it
reports how sure it was. demographics
predicts a number (income) with Regress and is scored on how
close it gets. Both split the same way: a trainer that writes a model file,
and a predictor that reads one. iris trains in about four seconds, which
makes it the one to run when changing this machine.
You don't need any of this to use the machine — it's here for the curious.
A Net holds W1 (hidden×input),
B1, W2 (output×hidden), and B2.
The forward pass is two lines of matrix algebra:
h = Map(VAdd(MatVec(W1, x), B1), Tanh), then
o = VAdd(MatVec(W2, h), B2). NetEvalAt returns both
h and o in a NetEval record, because
the backward pass needs the hidden activations it just computed.
The backward pass is four lines. For one example with
target y: the output signal is o - y. The hidden
signal carries that back through W2 transposed, then multiplies
by the tanh derivative, written (1 - h)(1 + h) from the
activations themselves. Each weight matrix's gradient is the
Outer product of its layer's signal with its layer's input. The
result is assembled straight into a Net — gradient and network,
same shape.
Both heads share that pass, and the reason is a small piece of
calculus worth carrying around. Differentiate squared error through
an identity output, and the error signal arriving at the output
pre-activation is output - target. Differentiate cross-entropy
through a softmax output, and it is probabilities - target. The
exponentials and the logarithm cancel exactly, and what is left is the same
subtraction. So NetGradAt has precisely one line that knows which
head it is working for — oSig, where the head decides how to read
the output layer. Every other line of the backward pass, every weight
gradient, the mini-batch averaging, the momentum, and the serialization are
shared verbatim. Regression's NetGrad is a one-line wrapper over
it rather than a second copy, so the two cannot drift apart. This is asserted,
not assumed: the machine's tests difference both losses numerically against
the analytic gradient, every parameter, and swapping one head for the other
makes them fail.
That shape-sharing is the whole design. A mini-batch step
is Fold-ing NetAdd over the per-example gradients,
one NetScale by 1/batchsize to average, and one more
NetScale by -lr plus a final NetAdd to
descend. The classic imperative version zeroes four accumulator arrays and
runs ten nested loop blocks. The algebra here has no accumulators at all —
purity made the bookkeeping vanish. The Tanh activation itself is
a runtime builtin (it needs native precision and saturates safely at
±1). That is the same division of labor the whole library follows: the
runtime supplies the primitives, Shoddy derives the rest.
Every word, plus the Net, NetEval, Head, Plan, Scaler and Model types
| Word | Description |
|---|---|
| Net | A network (or a gradient, or a momentum velocity — same
shape): W1,
the hidden×input weight matrix; B1, the hidden biases;
W2, the output×hidden weight matrix; B2, the
output biases (length 1 for regression, one per class for
classification). |
| NetEval | One forward pass's results: Hidden, the
post-tanh hidden activations (backpropagation needs them), and
Output, the output vector. |
| Head | How to read the output layer: Regress()
takes it as-is and fits squared error; Classify() pushes it
through Softmax and fits cross-entropy. |
| Plan | Everything NetFit needs that isn't the data,
as one value: Task (a Head), Rate,
Moment (the momentum coefficient), Batch,
Epochs. |
| Scaler | A per-column input transformation: Center
and Spread, one of each per input. Named so rather than Mean and
StdDev, which stats already declares. |
| Model | A predictor, complete: Weights (a
Net), Task (its Head), and
Prep (its Scaler). A Net alone is not
enough to predict with — whoever trained it also chose a head and a
scaling. |
| Word | Description |
|---|---|
| NetNew(ni, nh, no) | A fresh ni-nh-no
network with weights and biases uniform in [-0.01, 0.01] via Rnd.
Call Seed first for a reproducible net. |
| NetNewScaled(ni, nh, no) | The same, but Xavier/Glorot
initialized — each layer's weights uniform in
±Sqr(6 / (fanIn + fanOut)), so the spread narrows as a
layer widens, and biases at zero. Prefer this whenever a layer has more than a
handful of inputs. NetNew's fixed ±0.01 starts every hidden
unit within a whisker of zero, deep in the linear part of Tanh, and the units
take a long time to differentiate. |
| NetZero(n) | A net of the same shape with every weight zero — the starting velocity for momentum. |
| NetAdd(a, b) | Fieldwise sum of two same-shaped nets
(MatAdd/VAdd per field). Accumulates gradients;
applies updates. |
| NetScale(n, s) | Every weight and bias multiplied by
s. Averages gradients; applies the learning rate. |
| NetFromList(ws, ni, nh, no) | A net from a flat weight array in
the serialization order (see NetSave) — the inverse of
NetWeightList. |
| NetWeightList(n) | Every weight and bias as one flat list in the serialization order. |
| Word | Description |
|---|---|
| NetEvalAt(n, x) | The full forward pass for input x:
a NetEval with the hidden activations and the output
vector. |
| NetOut(n, x) | Just the regression value — the first element of
the output. The everyday prediction word for a Regress
net. |
| NetPredict(n, head, x) | The whole output vector read through the
head: the raw output under Regress, probabilities summing to 1
under Classify. |
| NetClassOf(n, x) | The predicted class as a 1-based index. Softmax is monotonic, so this is the largest output and the exponentials are never computed. |
| Softmax(zs) | Turns scores into probabilities summing to 1. The
largest score is subtracted first. That cannot change the result — softmax is
shift-invariant — and it keeps Exp away from overflow. |
| HeadOut(head, z) | Applies a head to an output vector: identity
for Regress, Softmax for Classify. |
| Argmax(zs) | The 1-based position of the largest element; the first of a tie. |
| NetOneHot(k, n) | A class index as a target vector of length
n: 1 at position k, 0 elsewhere. |
| Word | Description |
|---|---|
| NetGradAt(n, head, x, ys) | One example's gradient against a
vector target, as a Net with the same shape as n.
The machine's only backward pass; the head chooses how the output layer is
read and nothing else in it changes. |
| NetGrad(n, x, y) | The scalar-target regression form — a wrapper
over NetGradAt, not a second copy. |
| NetStep(n, xs, ys, lr) | One mini-batch step: the batch's
gradients averaged, scaled by -lr, and added to
n. |
| NetTrain(n, xs, ys, lr, batSize, epochs, report) | The
regression training loop. Each epoch shuffles the rows, tiles them into
Floor(n / batSize) mini-batches (remainder dropped), folds
NetStep over them, then calls report with
(epoch, net). The report runs after every epoch, so the
caller decides when to print and pay for metrics. Pass
[ Drop Drop ] for silence. |
| ClassPlan(lr, mu, batch, epochs) | A Plan with the
Classify head. |
| RegressPlan(lr, mu, batch, epochs) | A Plan with the
Regress head. |
| NetFit(n, plan, xs, ys, report) | The general training loop:
either head, vector targets, momentum. It threads net and velocity together
through every batch (v = mu·v - lr·grad, then the
net moves by v), and it reports after every epoch exactly as
NetTrain does. The velocity is discarded at the end — it is
scaffolding for the descent, not part of the model. With
Moment = 0 it is NetTrain, bit for bit. |
| Word | Description |
|---|---|
| NetMse(n, xs, ys) | Mean squared error of the net's predictions over a data set. Regression. |
| NetAccuracy(n, xs, ys, pctClose) | The fraction of rows
predicted within pctClose of the actual value —
|pred - actual| < pctClose × |actual|. Percent-close
accuracy, the natural score for regression. |
| NetLogLoss(n, xs, ys) | Mean cross-entropy over a data set: the negative log of the probability the net gave the true class. It is zero when the net was certain and right. It climbs without bound as the probability assigned to the truth goes to zero. The convergence signal for classification, where MSE means nothing. |
| NetClassAccuracy(n, xs, ys) | Winner-takes-all accuracy: the fraction of rows whose largest output sits where the one-hot target's 1 is. |
| Word | Description |
|---|---|
| ScalerFit(xs) | A Scaler from a set of input rows:
each column's mean, and its population standard deviation. A column that never
varies gets a spread of 1, not 0. Such a column carries no information either
way, and 1 maps it to a harmless constant instead of dividing by zero. Fit
this on the training rows only. |
| ScalerApply(s, x) | One input row centred and scaled. |
| ScalerNone(ni) | The identity scaler, for inputs already on comparable scales. |
| Word | Description |
|---|---|
| NetSave(path, n) | Writes the net as text, one number per line, in the classic demo order: input-hidden weights input-major, hidden biases, hidden-output weights hidden-major, output biases — so weight files interchange with the C# reference program. |
| NetLoad(path, ni, nh, no) | Reads a weight file written by
NetSave back into a net. Stops the program with an error if the
file's line count doesn't match the ni-nh-no
architecture. |
| NetSaveBin(path, n) | Writes the net as binary — a
self-describing header (a magic number — a fixed marker that identifies the
format — then ni, nh,
no), then every weight as a raw 8-byte double in the same
canonical order, through recio. Nothing is rounded,
so the round trip is bit-exact. |
| NetLoadBin(path) | Reads a binary model back into a net. The header carries the architecture, so no dimension arguments. The magic number and the exact byte size are checked before any weight is trusted, and a wrong or truncated file stops the program with a clear error. |
| ModelSave(path, m) | Writes a whole Model — weights,
head, and scaler — with the same binary discipline: a five-double header (its
own magic, then ni, nh, no, then the
head), the weights in the canonical order, then the scaler's centres and
spreads. Its magic differs from NetSaveBin's, so the two formats
can never be mistaken for one another. Files already written by
NetSaveBin stay readable by the word that wrote them. |
| ModelLoad(path) | Reads one back. Self-describing, so no architecture arguments; magic and exact byte size checked before anything is trusted. |
| ModelPredict(m, x) | Predict from a raw input row: the model applies the scaling it remembers, then its own head. This is the word that makes a saved model a complete predictor. The caller never reconstructs the training-time transformation, so it cannot get it wrong. |
| ModelClassOf(m, x) | The predicted class index from a raw input row. |
ModelSave unless you have a reason not to. A
NetSaveBin file is weights alone, so the program that
loads it must know the head and reproduce the input scaling from somewhere
else — usually by hand, in another file, where nothing will notice if the two
ever stop agreeing. NetSave's text format remains the one to use
when the weights must interchange with the C# reference program.| User | How | |
|---|---|---|
| demographics | The 8-100-1 regression — mini-batch SGD with momentum and the binary save/load pair. | |
| iris | The 4-8-3
classifier — softmax and cross-entropy, the input Scaler, and
the saved model the predictor reloads. |
| Machine | Why | |
|---|---|---|
| file | ReadLines
/ WriteLines carry the text model format. | |
| matrix | The algebra under
every forward and backward pass — MatVec, Outer,
Transp and the vector arithmetic. | |
| random | Shuffle
deals the mini-batches; RandomRange scatters the initial
weights. | |
| recio | GetRec
/ PutRec are the binary model file — the bit-exact save/load
contract. | |
| seq | ZipWith walks a layer's weights against its inputs. |