Embedded HandbookApps
All sections › XIV. Machine learning on a microcontroller
STM32F407 · level intermediate · 16 min read
Open in the interactive handbook

116б. How a neural network learns: the neuron, activation functions, the loss function, backpropagation, gradient descent, SGD, momentum and Adam

In 116.6 the perceptron was trained by scikit-learn in one line, in 118 the same way, in 119 by TensorFlow. Here that line is taken apart: what a neuron computes, how the error is measured, how the chain rule gives the gradient of every weight, why descent with momentum and Adam beat a plain step, and why a network overfits. Everything is written from scratch in numpy, without a framework, on a task with two pump sensors. The trained 2 → 16 → 3 network then goes to C: bytes on the F407, instructions per answer in QEMU, and C answers checked against numpy.

Sources: numpy 2.3.3 in the stm-ml:1 image, the train116c.py script — every number in the section comes from its run, and a rerun produces the same files. The C inference is built with gcc 12.2.0 (stm-sys:1) for the cross-check, with armclang 6.24 for the F407 for sizes, and with arm-none-eabi-gcc 12.2.1 and QEMU 7.2 (stm-arm:1, the netduinoplus2 board with an STM32F405) to count instructions.

Terms used in the documentation

Term Meaning
neuron, weight, bias a weighted sum of inputs plus an offset
activation function the non-linear function applied to that sum
sigmoid, tanh, ReLU the logistic curve, the hyperbolic tangent, the "rectifier" max(0, x)
forward pass from the input to the answer
logits the network outputs before softmax
loss function one number for "how bad"
mean squared error (MSE) the average of the squared error
cross-entropy minus the log of the probability of the right class
backpropagation carrying the error back from the output to the input
gradient check comparing the gradient with finite differences
learning rate the size of the descent step
epoch, mini-batch an epoch is one pass over the whole set; a batch is the part of it used for one step
stochastic gradient descent (SGD) a step on each batch
momentum the step accumulates speed
Adam descent with momentum and its own step scale for every weight
Xavier (Glorot), He initialization initial weights with a spread set by the number of inputs
vanishing gradient the gradient fades towards the first layers
overfitting, regularization learning the examples instead of the rule, and the remedies
weight decay (L2), dropout, early stopping a penalty on large weights, switching neurons off, stopping at the best epoch

116б.1 The neuron, the layer, the forward pass

The task: a centrifugal pump, a pressure sensor on the outlet (bar) and a flow meter (l/min). A healthy pump sits on the rated curve P = 4,0 · (1 − (Q/60)²); a worn impeller gives less head at the same flow. Three classes: normal — 92…104 % of the rated head, wear — 74…88 %, severe wear or air ingress — 50…66 %. The pressure sensor noise is 0,05 bar, the flow meter noise 0,6 l/min. The data are synthetic, with a fixed seed: 600 examples for training, 300 for tuning and 300 for testing, one third per class. The boundary between the classes is a curve; a straight line cannot draw it.

A neuron is a weighted sum of the inputs plus a bias, passed through an activation function: h = f(w₁x₁ + w₂x₂ + b). A layer of 16 neurons is a 2 × 16 weight matrix: a = W·z + b, one multiplication for all neurons at once. The forward pass goes layer by layer from the input to the logits:

    for (int j = 0; j < N_HID; j++) {
        float a = NN_B1[j];
        for (int i = 0; i < N_IN; i++)
            a += z[i] * NN_W1[i * N_HID + j];          /* a = W·z + b */
        h[j] = a > 0.0f ? a : 0.0f;                    /* ReLU */
    }

The 2 → 16 → 3 network has 99 parameters and 80 multiplications per answer. Without an activation function two layers collapse into one: a product of matrices is again a matrix, and the network stays a straight boundary.

Activation functions. The step (0 or 1) is the most direct "fired or not", but its derivative is zero everywhere — there is no gradient and nothing to learn with. The sigmoid 1/(1 + e⁻ˣ) is smooth, but its derivative is at most 0,25, and at x = 4 only 0,0177. For tanh the largest derivative is 1, at x = 4 it is 0,0013. ReLU = max(0, x): the derivative is 1 for any x > 0, and it costs one comparison on the device. The same 2 → 16 → 3 network, Adam, 60 epochs, different activations in the hidden layer:

Hidden layer Training loss Correct out of 300
none (output directly, straight boundaries) 0,4123 260
sigmoid 0,1900 276
tanh 0,0837 287
ReLU 0,0605 290

116б.2 The loss function: MSE for a number, cross-entropy for classes

To descend, you need one number for "how bad" and its derivative with respect to every output.

A number — the mean squared error. L = mean (ŷ − y)², the derivative with respect to the output is 2(ŷ − y)/N. Example: estimate what percentage of the rated head the pump delivers. The linear 2 → 1 model is off on the test set on average (root mean square) by 6,85 %, at the worst point by 22,38 %; the 2 → 16 → 1 network by 3,04 % and 12,13 %: a straight line cannot follow the curve of the rated characteristic.

A class — softmax and cross-entropy. The three logits are turned into probabilities pᵢ = e^sᵢ / Σ e^sⱼ, the loss is minus the log of the probability of the right class. Logits (2; 1; 0,1) give probabilities 0,659; 0,242; 0,099: if the first class is right, the loss is 0,417, if the third, 2,317. A network that answers "all three are equal" has a loss of ln 3 = 1,0986; our network with random initial weights has 1,4452. The derivative with respect to the logits for the softmax + cross-entropy pair is simple — p − y:

def ce_loss(s, y):
    """Перекрёстная энтропия после softmax и её градиент по логитам: (p − y) / N."""
    lp = log_softmax(s)
    n = len(y)
    g = np.exp(lp)
    g[np.arange(n), y] -= 1.0
    return -lp[np.arange(n), y].mean(), g / n

116б.3 Backpropagation: the chain rule by hand

For a two-layer network a₁ = W₁z + b₁, h = f(a₁), s = W₂h + b₂ the derivatives go from the output to the input; every link is one derivative:

    for i in range(len(P) - 1, -1, -1):
        h_in, a, hn, mask = cache[i]
        if i < len(P) - 1:                        # через активацию: dL/da = dL/dh · f'(a)
            if mask is not None:
                g = g * mask
            if not bug:
                g = g * df(a, hn if mask is None else ACT[act][0](a))
        W = P[i][0]
        grads[i] = [h_in.T @ g + 2.0 * l2 * W, g.sum(axis=0)]
        g = g @ W.T                               # в предыдущий слой

Your own backward pass must be checked. Finite differences: shift one weight by ±10⁻⁵, compute the loss twice, divide the difference by 2·10⁻⁵ — and do this for every weight. On 5 examples, for the 2 → 4 → 3 network (27 parameters) the largest relative difference from the formulas is 2,4·10⁻¹⁰, for the 2 → 4 → 1 network with MSE (17 parameters) 7,1·10⁻¹¹. A deliberately forgotten ReLU derivative gives 1,0: the check catches the error at once. The device has no backward pass — it is needed only for training.


116б.4 Gradient descent: the step, SGD, momentum and Adam

A descent step: w ← w − η·∂L/∂w. The key here is the learning rate η. Descent on the whole set (one step per epoch), 300 epochs, the same starting point:

Rate η Loss after 300 epochs Correct out of 300 Largest loss on the way
0,01 0,6497 230 1,5437
0,1 0,2108 288 1,3793
1 0,0707 289 1,1649
3 0,0744 282 2,7296
10 1,3947 100 2,2 million

Too small is slow: at 0,01 the network did not even reach 0,6 in 300 epochs. Too large jumps: at 3 the loss rose to 2,73 on the way, at 10 to millions, and the network collapsed into one class (100 out of 300 — exactly one third).

Training loss per epoch for four kinds of descent on the 2 → 16 → 3 network and 600 examples: descent on the whole set with rate 1 jumps in the first epochs and reaches 0,157 after 60 epochs, SGD with batch 32 and rate 0,1 reaches 0,092, momentum 0,9 with rate 0,03 reaches 0,069, Adam with rate 0,01 reaches 0,060; the loss axis is logarithmic

SGD. The gradient over a batch of 32 examples is a noisy but cheap estimate of the full one: 19 steps per epoch instead of 1. Momentum accumulates speed: v ← 0,9·v + g, w ← w − η·v — in a steady direction the step grows, across oscillations it is damped. Adam keeps two averages — of the gradient and of its square — and divides one by the root of the other: every weight gets its own step scale.

                    elif opt == "momentum":
                        st[li][q] = 0.9 * st[li][q] + G[li][q]
                        step = lr * st[li][q]
                    else:                                       # Adam: β1 0,9, β2 0,999
                        st[li][q] = 0.9 * st[li][q] + 0.1 * G[li][q]
                        st2[li][q] = 0.999 * st2[li][q] + 0.001 * G[li][q] ** 2
                        mh = st[li][q] / (1 - 0.9 ** t)
                        vh = st2[li][q] / (1 - 0.999 ** t)
                        step = lr * mh / (np.sqrt(vh) + 1e-8)
Method Rate Loss after 10 epochs After 60 epochs Epochs to loss < 0,2 Correct out of 300
descent on the whole set 1 0,4840 0,1569 35 286
SGD, batch 32 0,1 0,2819 0,0917 18 289
momentum 0,9, batch 32 0,03 0,1375 0,0694 6 288
Adam, batch 32 0,01 0,1757 0,0605 9 290

Adam gave the lowest loss after 60 epochs and the best test result; the same Adam with rate 0,01 trains the network in 119, and in section 118а the same mechanics is hidden inside Keras.


116б.5 Initialization, the vanishing gradient, overfitting

Not with zeros. All weights zero — ReLU has neither output nor gradient: the loss stays at 1,0986 = ln 3, the network answers with one class (100 out of 300). All weights equal (0,5) — the 16 neurons get the same gradient and remain copies of one: after training there is 1 distinct neuron, and 256 out of 300 are correct. Randomness breaks this symmetry, and the spread is chosen by the number of inputs: Xavier — σ = √(2/(n_in + n_out)) for the sigmoid and tanh, He — σ = √(2/n_in) for ReLU.

A deep network. Ten hidden layers of 32 neurons, the initial weights, one gradient estimate on 600 examples:

Network Signal spread: 1st layer / 10th Weight gradient: 1st layer / output
sigmoid, Xavier 7,55·10⁻² / 1,28·10⁻¹ 8,43·10⁻⁸ / 7,30·10⁻¹
tanh, Xavier 2,74·10⁻¹ / 1,54·10⁻¹ 1,07·10⁻¹ / 2,61·10⁻¹
ReLU, He 7,92·10⁻¹ / 1,34 5,04·10⁻¹ / 3,48
ReLU, σ = 1 7,92·10⁻¹ / 3,51·10⁵ 5,21·10⁵ / 1,61·10⁶
ReLU, σ = 0,01 7,92·10⁻³ / 3,51·10⁻¹⁵ 2,62·10⁻¹⁵ / 2,67·10⁻¹⁵

With the sigmoid the first-layer gradient is millions of times smaller than the output one: at every layer it is multiplied by the derivative of the sigmoid, which is at most 0,25. This is the vanishing gradient. SGD, rate 0,1, 30 epochs: the sigmoid network moved the loss from 1,1215 only to 1,1017 and answers with one class (100 out of 300); the same network with ReLU and He went from 0,8048 to 0,1448, 279 correct. Weights that are too large blow the signal up, weights that are too small damp it to zero.

Overfitting. Little data and a big network: 60 examples (20 per class), a 2 → 64 → 64 → 3 network (4547 parameters), Adam, 1500 epochs. The network learns all 60 examples, the loss on them is zero, but on the test set it is worse than the small network on 600 examples (292, 116б.6):

Method Correct in training out of 60 Correct in test out of 300 Training loss Test loss
no regularization 60 278 0,0000 0,3618
L2, λ = 0,003 60 283 0,0459 0,1449
dropout 0,3 60 276 0,0005 0,3606
early stopping (epoch 95) 60 276 0,0367 0,1567

L2 penalizes large weights and gives the best result. Early stopping takes the weights from the epoch where the loss on the tuning set (300 examples, not used in training) was lowest: the test loss fell from 0,3618 to 0,1567. Dropout did not help here (276 against 278): a regularization technique is checked on the tuning set like any other. The best remedy is more data.


116б.6 On the Cortex-M4: C inference, bytes and instructions

The final 2 → 16 → 3 network: Adam, rate 0,01, batch 32, up to 200 epochs, early stopping chose epoch 171. Correct: 594 out of 600 in training, 294 out of 300 in tuning and 292 out of 300 in testing. Severe wear is recognized in all 100 cases; the network took normal for wear 6 times, and wear once for normal and once for severe wear.

On the device softmax is not needed for the decision: the index of the largest logit is the same. Probabilities are computed only when confidence is needed, and then the largest logit is subtracted first:

    for (int c = 0; c < N_OUT; c++) {
        p[c] = expf(s[c] - m);
        sum += p[c];
    }

The cross-check. The weights, the input mean and spread are exported to model116c.h as float. The same infer116c.c, built with gcc on the computer, gave on the 300 test examples the same classes as numpy in 300 cases out of 300; the logits differed by at most 1,1·10⁻⁵ (C computes in float, numpy in double), the probabilities by 1,9·10⁻⁶. In QEMU on the Cortex-M4F — also 300 out of 300.

Bytes on the F407 (armclang, -mcpu=cortex-m4 -mfpu=fpv4-sp-d16):

-O2 -Os
nn_forward 1196 bytes 228 bytes
nn_classify 56 bytes 54 bytes
nn_probs 132 bytes 116 bytes
object code with literals 1588 bytes 498 bytes
constant tables 216 bytes 344 bytes

There are 103 weights and normalization numbers, which is 412 bytes of flash. With -O2 the compiler unrolled the loops — nn_forward has 64 VMUL.F32 instructions in a row, and some weights went into the literal pool inside the code; with -Os the loops stayed loops. RAM is only the arrays on the stack: z, h and s, 21 numbers.

Instructions per answer — QEMU netduinoplus2 with -icount, SysTick counts, code by arm-none-eabi-gcc -O2: nn_classify — 677 instructions per call including the test loop, nn_probs with expf — 903. These are instructions, not cycles: there are more cycles on the chip, so the time below is a lower bound. At 168 MHz it is at least 4,0 µs per answer.

What other networks there are — briefly, details in their own sections:

Architecture What it does On a microcontroller Where in the handbook
dense (fully connected) every input is connected to every neuron features from sensors: from a few to tens of inputs 116.6, 118, this section; Keras — section 118а; int8 — 119
convolutional one small filter slides over the signal or the image sound spectrograms, images 121б (sound), 121в (camera with an NPU)
recurrent keeps a state between the steps of a series long series; in this part a window with features replaces it (117) —
autoencoder compresses and restores the input; the restoration error measures "not like that" anomaly detection, learns only from normal data 120 (64 → 8 → 64)
transformer every step of a series looks at all the others large models; not used in this part —

116б.7 Pitfalls

The network does not learn: the loss is stuck around 1. The input is not normalized: the flow is in tens of l/min, the pressure in units of bar. Without normalization SGD over 60 epochs: rate 0,1 — loss 1,0295 and 125 correct out of 300, rate 0,01 — 0,9996 and 146, rate 0,001 — 2,0561 and 100. With normalization the same SGD reaches 0,0917 and 289. The mean and spread are part of the model; they go into the firmware together with the weights.

NaN in the probabilities. expf in float already overflows at 89 (e⁸⁸ is still finite). Logits (1000; 999; 0) computed head-on give two NaN out of three, after subtracting the largest one — 0,7311; 0,2689; 0,0000. The same in numpy: log_softmax starts with s - s.max(...).

The loss jumps up, then NaN. The learning rate is too large: at 10 — millions (116б.4). Reduce the rate until the curve is smooth, or take Adam with 0,01.

SGD learns worse than descent on the whole set. The data are not shuffled: the examples lie by class, and consecutive batches pull the network towards one class, then towards another. Without shuffling, after 60 epochs the loss is 0,1475 and 272 correct; with shuffling every epoch — 0,0917 and 289.

Your own backward pass "almost works". An error in a gradient formula is invisible by eye: the loss is computed by the forward pass, and that one is correct. The finite-difference check shows 1,0 instead of 10⁻¹⁰ — run it on every new formula.

All the neurons are the same. The weights were started at zero or at one constant — only random initialization breaks the symmetry (116б.5).


116б.8 Summary

Full sources

infer116c.c — C inference of the network in float with weights from model116c.h.

/* Раздел 116б: обученная на numpy сеть 2 → 16 → 3 — вывод на Cortex-M4F во float.
 * Веса — из model116c.h (выгрузил train116c.py). Вход — расход, л/мин, и давление, бар,
 * ответ — класс: 0 — норма, 1 — износ колеса, 2 — сильный износ или подсос воздуха.
 * На компьютере (-DML_HOST) main сверяет классы, логиты и вероятности с numpy
 * на проверочной выборке. Проверка — _verify/ml116c_check.py. */
#include <math.h>
#include <stdint.h>

#if defined(ML_HOST) && !defined(ML_DATA)
#define ML_DATA
#endif
#include "model116c.h"

/* Прямой проход: нормировка, скрытый слой с ReLU, выходной слой — логиты */
void nn_forward(const float x[N_IN], float s[N_OUT])
{
    float z[N_IN], h[N_HID];
    for (int i = 0; i < N_IN; i++)
        z[i] = (x[i] - NN_MU[i]) / NN_SD[i];           /* среднее и разброс — часть модели */
    for (int j = 0; j < N_HID; j++) {
        float a = NN_B1[j];
        for (int i = 0; i < N_IN; i++)
            a += z[i] * NN_W1[i * N_HID + j];          /* a = W·z + b */
        h[j] = a > 0.0f ? a : 0.0f;                    /* ReLU */
    }
    for (int c = 0; c < N_OUT; c++) {
        float a = NN_B2[c];
        for (int j = 0; j < N_HID; j++)
            a += h[j] * NN_W2[j * N_OUT + c];
        s[c] = a;
    }
}

/* Класс — номер наибольшего логита: softmax его не меняет и на приборе не нужен */
int nn_classify(const float x[N_IN])
{
    float s[N_OUT];
    nn_forward(x, s);
    int k = 0;
    for (int c = 1; c < N_OUT; c++)
        if (s[c] > s[k])
            k = c;
    return k;
}

/* Вероятности — когда нужна уверенность: сначала вычесть наибольший логит, иначе expf переполнится */
void nn_probs(const float x[N_IN], float p[N_OUT])
{
    float s[N_OUT], m, sum = 0.0f;
    nn_forward(x, s);
    m = s[0];
    for (int c = 1; c < N_OUT; c++)
        m = s[c] > m ? s[c] : m;
    for (int c = 0; c < N_OUT; c++) {
        p[c] = expf(s[c] - m);
        sum += p[c];
    }
    for (int c = 0; c < N_OUT; c++)
        p[c] /= sum;
}

#ifdef ML_HOST
#include <stdio.h>

int main(void)
{
    int same = 0, right = 0;
    float dl = 0.0f, dp = 0.0f;
    for (int i = 0; i < N_TEST; i++) {
        float s[N_OUT], p[N_OUT];
        int k = nn_classify(&TEST_X[N_IN * i]);
        nn_forward(&TEST_X[N_IN * i], s);
        nn_probs(&TEST_X[N_IN * i], p);
        same += k == NP_CLASS[i];
        right += k == TEST_Y[i];
        for (int c = 0; c < N_OUT; c++) {
            float e = fabsf(s[c] - NP_LOGIT[N_OUT * i + c]), f = fabsf(p[c] - NP_PROB[N_OUT * i + c]);
            dl = e > dl ? e : dl;
            dp = f > dp ? f : dp;
        }
    }
    printf("agree=%d,%d\nright=%d\n", same, N_TEST, right);
    printf("max_dlogit=%.1e\nmax_dprob=%.1e\n", (double)dl, (double)dp);
    return 0;
}
#endif

Next: section 117 "Data from the sensor: windows, features, CMSIS-DSP" — where the RMS and the peak frequency come from.

Open in the interactive handbook