๐Ÿ•ธ Neural Network & Backpropagation

The chain rule with its intermediate results kept - and gradient-checked
Neural Networks Intermediate
Contents

    Logistic regression could not separate the two planted epochs, because its boundary is a straight line. A neural network fixes that by stacking linear layers with a non-linear function between them.

    The non-linearity is the whole point. Two linear layers in a row collapse:

    $$W_2(W_1\mathbf{x}) = (W_2W_1)\mathbf{x} = W\mathbf{x}$$

    — still a single linear map, however many layers you add. Insert $\sigma$ between them and that collapse becomes impossible.

    Training needs the gradient of the loss with respect to every weight, and computing those one at a time would be hopeless. Backpropagation gets all of them in a single backward pass by applying the chain rule and reusing shared work.

    Below: the forward pass with every matrix printed, backpropagation derived and computed by hand, and — the check that matters — every analytic gradient compared against a numerical finite-difference estimate.

    Display helpers ready: banner, step, check, expand, show_matrix, show_matmul, show_elementwise.
    Network diagram helper ready: draw_network(layer_sizes, Ws, bs, ...).
       edge width = |weight|,  blue = positive,  red = negative

    1. The Data and the Architecture

    The same 20 EEG epochs. A deliberately tiny network so every matrix fits on the page:

    $$\underbrace{\mathbf{x}}_{2} \;\xrightarrow{W_1, \mathbf{b}_1}\; \underbrace{\mathbf{z}_1 \to \mathbf{a}_1}_{4 \text{ hidden, tanh}} \;\xrightarrow{W_2, \mathbf{b}_2}\; \underbrace{z_2 \to \hat{y}}_{1 \text{ output, sigmoid}}$$

    Parameter count: $W_1$ is $2\times4 = 8$, $\mathbf{b}_1$ is 4, $W_2$ is $4\times1 = 4$, $\mathbf{b}_2$ is 1 — 17 numbers in total.

    20 epochs x 2 features, classes [10, 10]
    
        id   spike_rate   line_length   class         label
    -------------------------------------------------------
      ep00          1.0          12.0       0    no seizure
      ep01          1.6          15.0       0    no seizure
      ep02          0.7          11.0       0    no seizure
      ep03          2.1          18.0       0    no seizure
      ep04          1.3          14.0       0    no seizure
      ep05          2.6          21.0       0    no seizure
      ep06          0.9          16.0       0    no seizure
      ep07          1.9          13.0       0    no seizure
      ep08          3.1          24.0       0    no seizure
      ep09          4.4          33.0       0    no seizure
      ep10          6.2          41.0       1       seizure
      ep11          7.1          47.0       1       seizure
      ep12          5.4          38.0       1       seizure
      ep13          8.0          52.0       1       seizure
      ep14          6.7          44.0       1       seizure
      ep15          5.9          49.0       1       seizure
      ep16          7.6          39.0       1       seizure
      ep17          6.0          35.0       1       seizure
      ep18          3.6          27.0       1       seizure
      ep19          8.3          46.0       1       seizure
    -------------------------------------------------------
    
    feature means : [ 4.22 29.75]
    feature stds  : [ 2.564 13.794]
    
    Note the scales: line_length spans ~41 while spike_rate spans ~8.
    Any algorithm that measures DISTANCE will be dominated by the larger one
    unless the features are scaled - that matters for KNN and SVM below.
    architecture: 2 -> 4 (tanh) -> 1 (sigmoid)
    parameters: W1 (2, 4) + b1 (1, 4) + W2 (4, 1) + b2 (1, 1) = 17
    
    W1 (initial) (2, 4) =
                         h0        h1        h2        h3
       spike_rate |   +0.250    +0.794    +0.551    -0.550
       line_length |   -0.400    +0.747    -0.989    +0.642
    
    W2^T (initial) (1, 4) =
                         h0        h1        h2        h3
              out |   +0.651    -0.070    -0.432    -0.485
    
    Xavier init draws from U(-a, a) with a = sqrt(6/(fan_in+fan_out)):
       layer 1: a = sqrt(6/(2+4)) = 1.000000
       layer 2: a = sqrt(6/(4+1)) = 1.095445
    
    Biases start at zero; weights must NOT, or every hidden unit would compute
    the same thing forever and the layer would be worth one unit.

    2. The Forward Pass

    $$\mathbf{z}_1 = XW_1 + \mathbf{b}_1 \qquad \mathbf{a}_1 = \tanh(\mathbf{z}_1)$$ $$z_2 = \mathbf{a}_1W_2 + b_2 \qquad \hat{y} = \sigma(z_2)$$

    and the loss is binary cross-entropy, the same one derived in the Logistic Regression walkthrough:

    $$\mathcal{L} = -\frac{1}{n}\sum_i\big[y_i\log\hat{y}_i + (1-y_i)\log(1-\hat{y}_i)\big]$$

    STEP 1 : z1 = X W1 + b1
    ==============================================================================
    ==============================================================================
      X (first 6 rows) (6, 2)   @   W1 (2, 4)   =   X W1 (6, 4)
    ==============================================================================
      shape rule: (6 x 2) @ (2 x 4) -> (6 x 4)   inner dim 2 cancels
      (20,2) @ (2,4) -> (20,4)
    ==============================================================================
    
    X (first 6 rows) (6, 2) =
             ep00 |   -1.256    -1.287
             ep01 |   -1.022    -1.069
             ep02 |   -1.373    -1.359
             ep03 |   -0.827    -0.852
             ep04 |   -1.139    -1.142
             ep05 |   -0.632    -0.634
    
    W1 (2, 4) =
                0 |   +0.250    +0.794    +0.551    -0.550
                1 |   -0.400    +0.747    -0.989    +0.642
    
    X W1 (6, 4) =
             ep00 |   +0.200    -1.959    +0.581    -0.136
             ep01 |   +0.172    -1.611    +0.495    -0.125
             ep02 |   +0.200    -2.106    +0.588    -0.119
             ep03 |   +0.134    -1.293    +0.387    -0.093
             ep04 |   +0.171    -1.758    +0.502    -0.108
             ep05 |   +0.095    -0.976    +0.279    -0.060
    
      worked element  X W1[0,0]  =  row 0 of X (first 6 rows)  .  column 0 of W1
          row 0 of X (first 6 rows) = [-1.256  -1.2867]
          col 0 of W1 = [ 0.2502 -0.3997]
          (-1.2560)*(+0.2502) + (-1.2867)*(-0.3997)
          = +0.200033   |  from array: +0.200033   |  MATCH: True
    
       then add b1 = [0. 0. 0. 0.] to every row
    
    
    STEP 2 : a1 = tanh(z1)
    ==============================================================================
    z1 (first 6) (6, 4) =
                         h0        h1        h2        h3
             ep00 |   +0.200    -1.959    +0.581    -0.136
             ep01 |   +0.172    -1.611    +0.495    -0.125
             ep02 |   +0.200    -2.106    +0.588    -0.119
             ep03 |   +0.134    -1.293    +0.387    -0.093
             ep04 |   +0.171    -1.758    +0.502    -0.108
             ep05 |   +0.095    -0.976    +0.279    -0.060
    
    a1 = tanh(z1) (6, 4) =
                         h0        h1        h2        h3
             ep00 |   +0.197    -0.961    +0.523    -0.136
             ep01 |   +0.170    -0.923    +0.458    -0.125
             ep02 |   +0.197    -0.971    +0.528    -0.118
             ep03 |   +0.133    -0.860    +0.369    -0.093
             ep04 |   +0.170    -0.942    +0.463    -0.107
             ep05 |   +0.095    -0.751    +0.272    -0.060
    
       tanh squashes into (-1, 1): a1 range [-0.9708, +0.9829]
       worked element a1[0,0] = tanh(+0.200033) = +0.197407   MATCH: True
    
    
    STEP 3 : z2 = a1 W2 + b2,   y_hat = sigmoid(z2)
    ==============================================================================
    ==============================================================================
      a1 (first 6) (6, 4)   @   W2 (4, 1)   =   a1 W2 (6, 1)
    ==============================================================================
      shape rule: (6 x 4) @ (4 x 1) -> (6 x 1)   inner dim 4 cancels
    ==============================================================================
    
    a1 (first 6) (6, 4) =
             ep00 |   +0.197    -0.961    +0.523    -0.136
             ep01 |   +0.170    -0.923    +0.458    -0.125
             ep02 |   +0.197    -0.971    +0.528    -0.118
             ep03 |   +0.133    -0.860    +0.369    -0.093
             ep04 |   +0.170    -0.942    +0.463    -0.107
             ep05 |   +0.095    -0.751    +0.272    -0.060
    
    W2 (4, 1) =
                0 |   +0.651
                1 |   -0.070
                2 |   -0.432
                3 |   -0.485
    
    a1 W2 (6, 1) =
             ep00 |   +0.036
             ep01 |   +0.038
             ep02 |   +0.026
             ep03 |   +0.033
             ep04 |   +0.029
             ep05 |   +0.026
    
      worked element  a1 W2[0,0]  =  row 0 of a1 (first 6)  .  column 0 of W2
          row 0 of a1 (first 6) = [ 0.1974 -0.961   0.5232 -0.1356]
          col 0 of W2 = [ 0.6508 -0.0703 -0.4315 -0.4854]
          (+0.1974)*(+0.6508) + (-0.9610)*(-0.0703) + (+0.5232)*(-0.4315) + (-0.1356)*(-0.4854)
          = +0.036043   |  from array: +0.036043   |  MATCH: True
    
       y_hat[0] = sigmoid(+0.036043) = 0.509010
    
    loss at initialisation = 0.706776
       (a model predicting 0.5 for everything would score 0.693147)

    3. Backpropagation

    The derivation is the chain rule applied layer by layer, working backwards. Two facts make it short.

    Fact 1 — the output layer collapses. Sigmoid with cross-entropy gives exactly the same cancellation as in logistic regression:

    $$\frac{\partial \mathcal{L}}{\partial z_2} = \hat{y} - y \;\equiv\; \delta_2$$

    Fact 2 — tanh has a cheap derivative. $\tanh'(z) = 1 - \tanh^2(z) = 1 - a_1^2$, so the forward activations are reused rather than recomputed.

    Propagating $\delta_2$ backwards:

    $$\boxed{\;\delta_2 = \hat{y}-y\;} \qquad \frac{\partial\mathcal{L}}{\partial W_2} = \frac{1}{n}\mathbf{a}_1^\top\delta_2 \qquad \frac{\partial\mathcal{L}}{\partial b_2} = \frac{1}{n}\sum_i \delta_2$$

    $$\boxed{\;\delta_1 = (\delta_2 W_2^\top)\odot(1 - \mathbf{a}_1^2)\;} \qquad \frac{\partial\mathcal{L}}{\partial W_1} = \frac{1}{n}X^\top\delta_1 \qquad \frac{\partial\mathcal{L}}{\partial b_1} = \frac{1}{n}\sum_i \delta_1$$

    Read $\delta$ as "how much this pre-activation is to blame". The whole algorithm is: compute blame at the output, pass it back through $W^\top$, scale it by the local derivative, repeat.

    delta_2 = (y_hat - y) / n     'blame' at the output
    
        id       y_hat     y    y_hat - y      delta_2
    --------------------------------------------------
      ep00    0.509010     0     0.509010     0.025450
      ep01    0.509614     0     0.509614     0.025481
      ep02    0.506454     0     0.506454     0.025323
      ep03    0.508158     0     0.508158     0.025408
      ep04    0.507163     0     0.507163     0.025358
      ep05    0.506610     0     0.506610     0.025331
      ep06    0.491352     0     0.491352     0.024568
      ep07    0.525498     0     0.525498     0.026275
       ...
    --------------------------------------------------
    
    
    delta_1 = (delta_2 W2^T) * (1 - a1^2)     blame pushed back one layer
    
       step a: delta_2 @ W2^T  -> distributes blame by connection strength
    delta_2 @ W2^T (5, 4) =
                         h0        h1        h2        h3
             ep00 |  +0.0165644   -0.0017879   -0.0109828   -0.0123548
             ep01 |  +0.0165840   -0.0017900   -0.0109958   -0.0123695
             ep02 |  +0.0164812   -0.0017789   -0.0109276   -0.0122928
             ep03 |  +0.0165366   -0.0017849   -0.0109644   -0.0123341
             ep04 |  +0.0165043   -0.0017814   -0.0109429   -0.0123100
    
       step b: multiply by the LOCAL derivative 1 - a1^2
    1 - a1^2  (tanh') (5, 4) =
                         h0        h1        h2        h3
             ep00 |  +0.9610305   +0.0764336   +0.7263055   +0.9816224
             ep01 |  +0.9710997   +0.1475768   +0.7904145   +0.9844613
             ep02 |  +0.9611457   +0.0575203   +0.7208205   +0.9860496
             ep03 |  +0.9823761   +0.2604145   +0.8640866   +0.9914426
             ep04 |  +0.9712000   +0.1121413   +0.7851823   +0.9885178
    
    delta_1 (5, 4) =
                         h0        h1        h2        h3
             ep00 |  +0.0159189   -0.0001367   -0.0079768   -0.0121278
             ep01 |  +0.0161047   -0.0002642   -0.0086912   -0.0121773
             ep02 |  +0.0158408   -0.0001023   -0.0078768   -0.0121213
             ep03 |  +0.0162452   -0.0004648   -0.0094742   -0.0122286
             ep04 |  +0.0160290   -0.0001998   -0.0085922   -0.0121687
    
       worked element delta_1[0,0]:
          = (+0.01656436) * (1 - (+0.197407)^2)
          = (+0.01656436) * (+0.96103052)
          = +0.0159188542
          from the array: +0.0159188542   MATCH: True
    
    
    gradients
    
    dL/dW1 (2, 4) =
                         h0        h1        h2        h3
       spike_rate |  -0.28078093   +0.00498967   +0.15223398   +0.20902192
       line_length |  -0.27837215   +0.00475220   +0.14475122   +0.20697491
    
    dL/dW2^T (1, 4) =
                         h0        h1        h2        h3
              out |  +0.06544873   -0.38496087   +0.17347403   -0.04000014
    
    dL/db1 = [ 0.0013  0.     -0.0021 -0.0038]
    dL/db2 = [-0.0006]

    4. Gradient Checking

    An analytic gradient that is subtly wrong will still train — badly, and without any error message. The only reliable defence is to compare against a numerical estimate:

    $$\frac{\partial\mathcal{L}}{\partial\theta} \approx \frac{\mathcal{L}(\theta+\epsilon) - \mathcal{L}(\theta-\epsilon)}{2\epsilon}$$

    The central difference is used rather than the forward one because its error is $O(\epsilon^2)$ instead of $O(\epsilon)$.

    This is the single most valuable debugging technique for hand-written networks, so all 17 parameters are checked below.

    parameter    analytic (backprop)         numerical    rel. error
    --------------------------------------------------------------------
        W1[0, 0]        -0.280780929775   -0.280780929784      1.69e-11
        W1[0, 1]         0.004989666683    0.004989666624      5.89e-09
        W1[0, 2]         0.152233975257    0.152233975370      3.72e-10
        W1[0, 3]         0.209021919936    0.209021919995      1.40e-10
        W1[1, 0]        -0.278372153082   -0.278372153051      5.55e-11
        W1[1, 1]         0.004752195135    0.004752195137      2.87e-10
        W1[1, 2]         0.144751218823    0.144751218789      1.17e-10
        W1[1, 3]         0.206974908490    0.206974908545      1.33e-10
        b1[0, 0]         0.001278761544    0.001278761486      2.28e-08
        b1[0, 1]         0.000010652309    0.000010652257      2.45e-06
        b1[0, 2]        -0.002109805294   -0.002109805275      4.49e-09
        b1[0, 3]        -0.003777413754   -0.003777413771      2.17e-09
        W2[0, 0]         0.065448733576    0.065448733544      2.43e-10
        W2[1, 0]        -0.384960871338   -0.384960871347      1.18e-11
        W2[2, 0]         0.173474031757    0.173474031806      1.40e-10
        W2[3, 0]        -0.040000139442   -0.040000139423      2.37e-10
        b2[0, 0]        -0.000618626289   -0.000618626317      2.28e-08
    --------------------------------------------------------------------
    
    parameters checked : 17
    max relative error : 2.451e-06
    overall relative error (norm form): 1.388e-10
    
    rule of thumb: below 1e-7 the gradient is correct, above 1e-4 it is not.
    verdict: CORRECT

    What a broken gradient looks like

    To show the check actually has teeth, here is the same test with one deliberately introduced bug: forgetting the $(1 - a_1^2)$ factor in $\delta_1$ — a genuinely common slip.

    parameter      buggy gradient         numerical    rel. error
    ----------------------------------------------------------------
        W1[0, 0]       -0.2902986242     -0.2807809298      1.67e-02  <-- WRONG
        W1[0, 1]        0.0313342208      0.0049896666      7.25e-01  <-- WRONG
        W1[0, 2]        0.1924782895      0.1522339754      1.17e-01  <-- WRONG
        W1[0, 3]        0.2165242658      0.2090219200      1.76e-02  <-- WRONG
        W1[1, 0]       -0.2903705458     -0.2783721531      2.11e-02  <-- WRONG
        W1[1, 1]        0.0313419839      0.0047521951      7.37e-01  <-- WRONG
        W1[1, 2]        0.1925259761      0.1447512188      1.42e-01  <-- WRONG
        W1[1, 3]        0.2165779098      0.2069749085      2.27e-02  <-- WRONG
             ...
    ----------------------------------------------------------------
    
    overall relative error, correct version : 1.388e-10  -> passes
    overall relative error, buggy version   : 5.362e-02  -> fails
    
    The buggy version is off by 4e+08x. Note that it would
    still 'train' - the loss would go down - just to a worse place, silently.

    5. Training

    $$\theta \leftarrow \theta - \eta\,\frac{\partial\mathcal{L}}{\partial\theta}$$

    Full-batch gradient descent: forward, backward, step, repeat.

    training, full batch, eta = 0.5
    
       epoch     0   loss 0.706776   accuracy 0.3000
       epoch   500   loss 0.136036   accuracy 0.9000
       epoch  1000   loss 0.123009   accuracy 0.9000
       epoch  1500   loss 0.083704   accuracy 0.9500
       epoch  2000   loss 0.022045   accuracy 1.0000
       epoch  2500   loss 0.009480   accuracy 1.0000
       epoch  3000   loss 0.005833   accuracy 1.0000
       epoch  3500   loss 0.004168   accuracy 1.0000
       epoch  4000   loss 0.003226   accuracy 1.0000

    The network, drawn — and what learning does to it

    Below is the actual network. Every circle is a neuron, every line a weight:

    • line thickness = $\lvert w \rvert$, how strongly that connection matters
    • blue = positive weight, red = negative
    • the number inside each hidden/output circle is its bias

    Watching the same diagram at four points during training is the clearest picture of what "learning" means: the structure never changes, only the numbers on the edges.

    how the 17 numbers move
    
      epoch       loss     ||W1||     ||W2||    max |w|   accuracy
    ------------------------------------------------------------
          0    0.70678     1.8465     0.9222     0.9895     0.3000
         50    0.16023     2.2045     2.2738     1.2835     0.9000
        500    0.13604     2.3822     3.7038     2.0816     0.9000
       4000    0.00323     9.6237    10.3979     6.1111     1.0000
    ------------------------------------------------------------
    
    At epoch 0 the edges are thin and evenly random - the network has no
    opinion. As training proceeds some connections thicken into the ones that
    carry the decision, and others fade toward zero. Nothing is added or
    removed; the same 17 numbers simply take on useful values.

    One epoch, every number

    The same loop as the Linear Regression walkthrough — predict, measure error, get the gradient, step — but now with two layers. Three epochs are traced in full, showing every weight before and after.

    ==============================================================================
      EPOCH 1        loss = 0.706776
    ==============================================================================
    
    FORWARD - what the network currently predicts (first 5 of 20)
    
        id      a1[0]      a1[1]      a1[2]      a1[3]      y_hat     y      error
    --------------------------------------------------------------------------
      ep00    0.19741   -0.96102    0.52316   -0.13556    0.50901     0    0.50901
      ep01    0.17000   -0.92327    0.45781   -0.12465    0.50961     0    0.50961
      ep02    0.19711   -0.97081    0.52837   -0.11811    0.50645     0    0.50645
      ep03    0.13275   -0.85999    0.36866   -0.09251    0.50816     0    0.50816
      ep04    0.16971   -0.94226    0.46348   -0.10716    0.50716     0    0.50716
    --------------------------------------------------------------------------
    
    BACKWARD - blame, layer by layer
    
       delta_2 (output)  : mean -0.000031,  largest |.| 0.027665
       delta_1 (hidden)  : h0=+0.000064  h1=+0.000001  h2=-0.000105  h3=-0.000189
    
    GRADIENTS
    
                             h0           h1           h2           h3
    --------------------------------------------------------------
       dL/dW1[spike_]   -0.2807809    0.0049897    0.1522340    0.2090219
       dL/dW1[line_l]   -0.2783722    0.0047522    0.1447512    0.2069749
       dL/db1            0.0012788    0.0000107   -0.0021098   -0.0037774
       dL/dW2            0.0654487   -0.3849609    0.1734740   -0.0400001
       dL/db2           -0.0006186
    --------------------------------------------------------------
    
    UPDATE   w <- w - 0.5 * gradient
    
              weight       before    - eta*grad        after
    --------------------------------------------------------
         W1[spik,h0]     0.250191     0.1403905     0.390581
         W1[spik,h1]     0.794428    -0.0024948     0.791933
         W1[spik,h2]     0.551371    -0.0761170     0.475254
         W1[spik,h3]    -0.549586    -0.1045110    -0.654097
         W1[line,h0]    -0.399667     0.1391861    -0.260481
         W1[line,h1]     0.747107    -0.0023761     0.744731
         W1[line,h2]    -0.989469    -0.0723756    -1.061845
         W1[line,h3]     0.642457    -0.1034875     0.538969
            W2[h0,y]     0.650847    -0.0327244     0.618122
            W2[h1,y]    -0.070251     0.1924804     0.122229
            W2[h2,y]    -0.431534    -0.0867370    -0.518271
            W2[h3,y]    -0.485445     0.0200001    -0.465445
                  b2     0.000000     0.0003093     0.000309
    --------------------------------------------------------
    
       loss: 0.706776  ->  0.505315
    
    ==============================================================================
      EPOCH 2        loss = 0.505315
    ==============================================================================
    
    FORWARD - what the network currently predicts (first 5 of 20)
    
        id      a1[0]      a1[1]      a1[2]      a1[3]      y_hat     y      error
    --------------------------------------------------------------------------
      ep00   -0.15478   -0.96055    0.64720    0.12919    0.35244     0    0.35244
      ep01   -0.12068   -0.92251    0.57218    0.09377    0.37116     0    0.37116
      ep02   -0.18084   -0.97043    0.65944    0.16584    0.34320     0    0.34320
      ep03   -0.10140   -0.85892    0.47191    0.08350    0.38917     0    0.38917
      ep04   -0.14702   -0.94164    0.58638    0.13076    0.36114     0    0.36114
    --------------------------------------------------------------------------
    
    BACKWARD - blame, layer by layer
    
       delta_2 (output)  : mean -0.000117,  largest |.| 0.026764
       delta_1 (hidden)  : h0=-0.000004  h1=-0.000002  h2=+0.000041  h3=-0.000099
    
    GRADIENTS
    
                             h0           h1           h2           h3
    --------------------------------------------------------------
       dL/dW1[spike_]   -0.1929496   -0.0065111    0.1153155    0.1429465
       dL/dW1[line_l]   -0.1937146   -0.0060672    0.1072491    0.1438504
       dL/db1           -0.0000818   -0.0000473    0.0008212   -0.0019854
       dL/dW2           -0.0415299   -0.2798715    0.1590587    0.0364284
       dL/db2           -0.0023352
    --------------------------------------------------------------
    
    UPDATE   w <- w - 0.5 * gradient
    
              weight       before    - eta*grad        after
    --------------------------------------------------------
         W1[spik,h0]     0.390581     0.0964748     0.487056
         W1[spik,h1]     0.791933     0.0032556     0.795188
         W1[spik,h2]     0.475254    -0.0576578     0.417597
         W1[spik,h3]    -0.654097    -0.0714732    -0.725570
         W1[line,h0]    -0.260481     0.0968573    -0.163624
         W1[line,h1]     0.744731     0.0030336     0.747764
         W1[line,h2]    -1.061845    -0.0536246    -1.115470
         W1[line,h3]     0.538969    -0.0719252     0.467044
            W2[h0,y]     0.618122     0.0207649     0.638887
            W2[h1,y]     0.122229     0.1399358     0.262165
            W2[h2,y]    -0.518271    -0.0795293    -0.597801
            W2[h3,y]    -0.465445    -0.0182142    -0.483659
                  b2     0.000309     0.0011676     0.001477
    --------------------------------------------------------
    
       loss: 0.505315  ->  0.396453
    
    ==============================================================================
      EPOCH 3        loss = 0.396453
    ==============================================================================
    
    FORWARD - what the network currently predicts (first 5 of 20)
    
        id      a1[0]      a1[1]      a1[2]      a1[3]      y_hat     y      error
    --------------------------------------------------------------------------
      ep00   -0.38148   -0.96116    0.72184    0.30337    0.25493     0    0.25493
      ep01   -0.31257   -0.92348    0.64496    0.24020    0.28049     0    0.28049
      ep02   -0.41937   -0.97092    0.73681    0.34897    0.24412     0    0.24412
      ep03   -0.25802   -0.86030    0.54093    0.20222    0.30787     0    0.30787
      ep04   -0.35270   -0.94244    0.66326    0.28768    0.26765     0    0.26765
    --------------------------------------------------------------------------
    
    BACKWARD - blame, layer by layer
    
       delta_2 (output)  : mean -0.000149,  largest |.| 0.028147
       delta_1 (hidden)  : h0=-0.000041  h1=-0.000005  h2=+0.000121  h3=-0.000037
    
    GRADIENTS
    
                             h0           h1           h2           h3
    --------------------------------------------------------------
       dL/dW1[spike_]   -0.1351589   -0.0105975    0.0892332    0.1047640
       dL/dW1[line_l]   -0.1363528   -0.0095995    0.0812392    0.1063079
       dL/db1           -0.0008293   -0.0000917    0.0024242   -0.0007451
       dL/dW2           -0.0750317   -0.2115924    0.1357589    0.0602998
       dL/db2           -0.0029788
    --------------------------------------------------------------
    
    UPDATE   w <- w - 0.5 * gradient
    
              weight       before    - eta*grad        after
    --------------------------------------------------------
         W1[spik,h0]     0.487056     0.0675795     0.554636
         W1[spik,h1]     0.795188     0.0052988     0.800487
         W1[spik,h2]     0.417597    -0.0446166     0.372980
         W1[spik,h3]    -0.725570    -0.0523820    -0.777952
         W1[line,h0]    -0.163624     0.0681764    -0.095448
         W1[line,h1]     0.747764     0.0047997     0.752564
         W1[line,h2]    -1.115470    -0.0406196    -1.156089
         W1[line,h3]     0.467044    -0.0531539     0.413890
            W2[h0,y]     0.638887     0.0375159     0.676403
            W2[h1,y]     0.262165     0.1057962     0.367961
            W2[h2,y]    -0.597801    -0.0678795    -0.665680
            W2[h3,y]    -0.483659    -0.0301499    -0.513809
                  b2     0.001477     0.0014894     0.002966
    --------------------------------------------------------
    
       loss: 0.396453  ->  0.330778

    The boundary forming, epoch by epoch

    The same training run seen from the outside: what the network actually predicts across the whole feature space as its weights change.

    epoch       loss   accuracy                                  what changed
    ----------------------------------------------------------------------------
          0    0.70678     0.3000           random weights, no structure at all
         20    0.17085     0.9000                  a rough linear split appears
         50    0.16023     0.9000                            the split sharpens
        150    0.14905     0.9000                   the boundary starts to bend
        600    0.13429     0.9000         curvature captures the awkward epochs
       4000    0.00323     1.0000                                     converged
    ----------------------------------------------------------------------------
    final loss     : 0.003226
    final accuracy : 1.0000  (20/20)
    misclassified  : none
    
    Logistic regression got 18/20 on this data and could not do better -
    its boundary is a straight line. The hidden layer removes that limit.
    Right panel: each coloured line is where one hidden unit switches sign -
    each is still LINEAR. The output layer combines these four half-planes,
    and the tanh between them is what lets the combination bend.

    Why the non-linearity is essential

    Replace $\tanh$ with the identity and the network becomes a product of matrices — an expensive way to write logistic regression. The comparison below makes the collapse concrete.

    model        loss   accuracy
    ----------------------------------------------------------
                  2-layer, tanh hidden    0.003226     1.0000
             2-layer, NO non-linearity    0.132144     0.9000
             plain logistic regression    0.132145     0.9000
    ----------------------------------------------------------
    
    the linear network's effective single weight vector W1 @ W2:
       [ 8.8381 -2.9434]   (bias +0.660633)
       direction, normalised : [ 0.9488 -0.316 ]
       logistic regression   : [ 0.9482 -0.3177]
       cosine similarity     : 0.99999834
    
    The 17-parameter linear network found the SAME direction as the
    3-parameter logistic regression, because that is all it can express.
    Two linear layers are one linear layer.

    6. Activations and Vanishing Gradients

    $\delta$ is multiplied by the activation's derivative at every layer. If that derivative is habitually smaller than 1, the product shrinks geometrically with depth — the vanishing gradient problem, and the reason deep networks were impractical before ReLU.

    Activation $f(z)$ $f'(z)$ max $f'$
    Sigmoid $1/(1+e^{-z})$ $f(1-f)$ 0.25
    Tanh $\tanh z$ $1 - f^2$ 1.0
    ReLU $\max(0,z)$ $0$ or $1$ 1.0
    what happens to a gradient passed back through L layers
    
     layers         sigmoid            tanh            ReLU
    --------------------------------------------------------
          1       2.500e-01       9.998e-01       1.000e+00
          5       9.764e-04       9.992e-01       1.000e+00
         10       9.533e-07       9.984e-01       1.000e+00
         25       8.873e-16       9.961e-01       1.000e+00
         50       7.873e-31       9.922e-01       1.000e+00
    --------------------------------------------------------
    
    With sigmoid the BEST case is 0.25 per layer, so after 25 layers the
    gradient is ~1e-15 - numerically zero. Early layers stop learning.
    tanh and ReLU have a maximum derivative of 1, which is why they replaced it.
    
    
    same network, three activations, 2000 epochs each:
    
      activation    final loss   accuracy
    --------------------------------------
            tanh      0.022045     1.0000
         sigmoid      0.137789     0.9000
            ReLU      0.115094     0.9500
    --------------------------------------

    7. Capacity

    The universal approximation theorem says one hidden layer with enough units can approximate any continuous function to arbitrary accuracy. It says nothing about how many units, or whether gradient descent will find them — and it comes with the usual price: enough capacity to fit anything includes enough capacity to fit noise.

    hidden units   parameters    final loss   train acc
    ----------------------------------------------------
                1            5      0.135725      0.9000
                2            9      0.134148      0.9000
                4           17      0.005833      1.0000
                8           33      0.133313      0.9000
               16           65      0.133089      0.9000
               32          129      0.132797      0.9000
    ----------------------------------------------------
    
    20 training points and up to 129 parameters - far more knobs
    than data. On a real problem that gap is where overfitting lives; here it
    is only being used to show the capacity dial working.
    One hidden unit can only produce a straight boundary - it is logistic
    regression again. Two can bend it. Sixteen wraps tightly around the
    individual awkward points, which is memorisation, not understanding.

    8. Checking Against scikit-learn

    MLPClassifier will not reproduce our exact weights — different initialisation, a different optimiser, and its own regularisation. What can be checked is that the same architecture reaches the same quality, and that our forward pass reproduces sklearn's outputs when handed sklearn's own trained weights.

    model   accuracy        loss
    ----------------------------------------------------
             ours (hand-written)     1.0000    0.003226
           sklearn MLPClassifier     1.0000    0.000027
    ----------------------------------------------------
    
    now run OUR forward pass using SKLEARN's trained weights:
    
       W1 (2, 4), b1 (4,), W2 (4, 1), b2 (1,)
    
        id     our forward    sklearn proba        diff
    ----------------------------------------------------
      ep00    0.0000020880     0.0000020880    0.00e+00
      ep01    0.0000028418     0.0000028418    0.00e+00
      ep02    0.0000018323     0.0000018323    0.00e+00
      ep03    0.0000051654     0.0000051654    8.47e-22
      ep04    0.0000020440     0.0000020440    4.24e-22
      ep05    0.0000184342     0.0000184342    3.39e-21
      ep06    0.0000000000     0.0000000000    0.00e+00
      ep07    0.0000082756     0.0000082756    1.69e-21
       ...
    ----------------------------------------------------
    
    all 20 outputs MATCH: True
    max difference: 5.421e-20
    
    That confirms the forward pass is exactly the operation sklearn performs.
    The gradient check in section 4 confirms the backward pass. Together they
    establish the implementation, without needing identical weights.

    9. Summary

    Stage Formula
    Forward, layer 1 $\mathbf{z}_1 = XW_1+\mathbf{b}_1$, $\;\mathbf{a}_1 = \tanh\mathbf{z}_1$
    Forward, layer 2 $z_2 = \mathbf{a}_1W_2+b_2$, $\;\hat{y} = \sigma(z_2)$
    Loss binary cross-entropy
    Output blame $\delta_2 = \hat{y}-y$
    Hidden blame $\delta_1 = (\delta_2W_2^\top)\odot(1-\mathbf{a}_1^2)$
    Gradients $\frac{1}{n}\mathbf{a}_1^\top\delta_2$, $\;\frac{1}{n}X^\top\delta_1$
    Update $\theta \leftarrow \theta - \eta\nabla_\theta\mathcal{L}$
    Verification central differences, rel. error $< 10^{-7}$

    The one idea: backpropagation is the chain rule with the intermediate results kept. A naive approach would recompute the shared prefix for every parameter; backprop computes each $\delta$ once and reuses it, which is what makes the cost of the backward pass roughly equal to the forward pass rather than proportional to the parameter count.

    What the non-linearity buys: without it, depth is an illusion — section 5 shows a 17-parameter linear network converging to exactly the direction that 3-parameter logistic regression finds. With it, the same network draws a boundary neither could.

    What is deliberately left out: mini-batch SGD and momentum, Adam and adaptive learning rates, dropout, batch normalisation, weight decay, learning-rate schedules, softmax with multiple classes, convolutional and recurrent structure, and automatic differentiation — which is this notebook's backward pass, generalised and generated rather than derived by hand.

    References

    1. Learning representations by back-propagating errors โ€” Rumelhart, Hinton & Williams, 1986 (Nature 323)
    2. Deep Learning, ch. 6 โ€” Goodfellow, Bengio & Courville, 2016