From One Neuron to a Network

Article · 10:00

A neuron is a weighted sum passed through a function. That one sentence, repeated and stacked, is the whole architecture - and the non-linear function in the middle is what makes depth worth anything at all.

How a network learns

Input passes forward through layers to produce a prediction, a loss function measures how wrong that prediction is against the known answer, backpropagation computes how much each weight contributed to the error, and the optimiser adjusts every weight a small step in the direction that reduces it. The cycle repeats.

One loop, repeated millions of times. Everything else in deep learning is a variation on it.

This lesson has 8 runnable examples. Press Try yourself! under any of them to open it in the editor.

A neuron computes a weighted sum of its inputs, adds a bias, and passes the result through a non-linear function. A network is those neurons in layers. Everything else in deep learning is a variation on arranging them.

One neuron

PY Example 1 18 lines
import numpy as np

rng = np.random.default_rng(3)

def relu(x):
    return np.maximum(0.0, x)

weights = np.array([0.6, -1.2, 0.3])
bias = 0.1
inputs = np.array([1.0, 0.5, -2.0])

weighted_sum = float(inputs @ weights + bias)
output = float(relu(weighted_sum))

print(f'inputs        {inputs}')
print(f'weights       {weights}')
print(f'weighted sum  {weighted_sum:.3f}')
print(f'after ReLU    {output:.3f}')

Open this example in the editor and change it. Nothing you type is saved.

That is a neuron. Three multiplications, an addition, and a function. A large language model is billions of those, and nothing about the individual unit changes.

Why the activation function matters

Without a non-linear activation, stacking layers is pointless: the composition of linear functions is a linear function.

PY Example 2 20 lines
import numpy as np

rng = np.random.default_rng(5)

W1 = rng.normal(0, 0.5, (4, 3))
W2 = rng.normal(0, 0.5, (3, 2))
x = rng.normal(0, 1, 4)

# Two layers with NO activation.
stacked = (x @ W1) @ W2

# One layer with the combined weight matrix.
combined = x @ (W1 @ W2)

print(f'two linear layers: {stacked.round(6)}')
print(f'one layer:         {combined.round(6)}')
print(f'identical: {bool(np.allclose(stacked, combined))}')
print()
print('A hundred linear layers are exactly one linear layer. The')
print('non-linearity is what makes depth mean anything.')

Open this example in the editor and change it. Nothing you type is saved.

The activations you will meet, and what each is for:

PY Example 3 23 lines
import numpy as np
import pandas as pd

x = np.array([-3.0, -1.0, -0.1, 0.0, 0.1, 1.0, 3.0])

def sigmoid(v):
    return 1 / (1 + np.exp(-v))

table = pd.DataFrame({
    'x': x,
    'relu': np.maximum(0, x).round(3),
    'leaky_relu': np.where(x > 0, x, 0.01 * x).round(3),
    'gelu': (0.5 * x * (1 + np.tanh(np.sqrt(2 / np.pi) * (x + 0.044715 * x ** 3)))).round(3),
    'tanh': np.tanh(x).round(3),
    'sigmoid': sigmoid(x).round(3),
})
print(table.to_string(index=False))
print()
print('relu     - the default for hidden layers: cheap, and its')
print('           gradient is 1 or 0, which does not vanish')
print('gelu     - a smooth relu; the default inside transformers')
print('sigmoid  - the OUTPUT layer for a binary probability')
print('tanh     - centred on zero; mostly historical for hidden layers')

Open this example in the editor and change it. Nothing you type is saved.

ReLU's weakness is the dead neuron: once its input is negative for every example, its gradient is zero and it never recovers.

PY Example 4 21 lines
import numpy as np

rng = np.random.default_rng(7)
n = 2000

# The input to a hidden layer is the PREVIOUS ReLU's output, so it is
# never negative. That matters: against a zero-mean input, a negative
# weight mean cancels out and nothing dies. It is the bias that kills,
# and a single too-large update is what drives it down.
inputs = np.maximum(0, rng.normal(0, 1, (n, 50)))
weights = rng.normal(0, 0.3, (50, 100))
bias = rng.normal(-2.0, 1.5, 100)      # after one oversized step

activations = np.maximum(0, inputs @ weights + bias)

is_dead = activations.max(axis=0) == 0
print(f'{is_dead.mean():.0%} of the 100 units never fire on any of {n} examples')
print(f'mean bias, dead units {bias[is_dead].mean():+.2f}')
print(f'mean bias, live units {bias[~is_dead].mean():+.2f}')
print('Their gradient is permanently zero. Leaky ReLU or GELU avoids')
print('this, and so does sensible initialisation.')

Open this example in the editor and change it. Nothing you type is saved.

What depth buys

A single layer of enough width can approximate any continuous function - that is a theorem. In practice depth is far more efficient than width for the same number of parameters.

PY Example 5 34 lines
import numpy as np
import pandas as pd
from sklearn.neural_network import MLPClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline

rng = np.random.default_rng(11)
n = 6000

# A problem that needs composition: a checkerboard in two dimensions
# plus a third interacting feature.
X = rng.uniform(-3, 3, (n, 3))
y = ((np.sin(X[:, 0] * 2) * np.sin(X[:, 1] * 2) > 0).astype(int)
     ^ (X[:, 2] > 0).astype(int))
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.3, random_state=0, stratify=y)

rows = []
for shape in [(2,), (16,), (256,), (16, 16), (16, 16, 16), (64, 64)]:
    parameters = sum(a * b for a, b in zip((3,) + shape, shape + (1,)))
    model = Pipeline([('s', StandardScaler()),
                      ('m', MLPClassifier(hidden_layer_sizes=shape, max_iter=600,
                                          random_state=0))]).fit(X_tr, y_tr)
    rows.append({
        'hidden layers': str(shape),
        'approx params': parameters,
        'test AUC': round(roc_auc_score(y_te, model.predict_proba(X_te)[:, 1]), 3),
    })
print(pd.DataFrame(rows).to_string(index=False))
print()
print('(16, 16) has far fewer parameters than (256,) and does better,')
print('because the problem is a composition of simple pieces - which')
print('is what depth is good at.')

Open this example in the editor and change it. Nothing you type is saved.

The intuition: each layer builds features out of the previous layer's features. One layer can only combine the raw inputs; two can combine combinations.

A small network, concretely

Here is a complete network - forward pass, loss, gradients and training - in numpy, with no framework. Everything a library does, it does this.

PY Example 6 76 lines
import numpy as np

rng = np.random.default_rng(seed=13)

# ---- the data: XOR, which no single linear layer can solve --------
n = 2000
X = rng.uniform(-1, 1, (n, 2))
y = ((X[:, 0] > 0) ^ (X[:, 1] > 0)).astype(float).reshape(-1, 1)

split = int(n * 0.8)
X_tr, X_te, y_tr, y_te = X[:split], X[split:], y[:split], y[split:]

# ---- the network: 2 -> 8 -> 1 -------------------------------------
HIDDEN = 8
# He initialisation: scale by sqrt(2 / fan_in), which keeps the
# activations from shrinking or exploding as depth grows.
W1 = rng.normal(0, np.sqrt(2 / 2), (2, HIDDEN))
b1 = np.zeros(HIDDEN)
W2 = rng.normal(0, np.sqrt(2 / HIDDEN), (HIDDEN, 1))
b2 = np.zeros(1)

def sigmoid(z):
    return 1 / (1 + np.exp(-np.clip(z, -60, 60)))

def forward(X, W1, b1, W2, b2):
    z1 = X @ W1 + b1
    a1 = np.maximum(0, z1)              # ReLU
    z2 = a1 @ W2 + b2
    a2 = sigmoid(z2)
    return z1, a1, z2, a2

def loss(predicted, actual):
    p = np.clip(predicted, 1e-9, 1 - 1e-9)
    return float(-np.mean(actual * np.log(p) + (1 - actual) * np.log(1 - p)))

LEARNING_RATE = 0.5
history = []

for epoch in range(1, 2001):
    z1, a1, z2, a2 = forward(X_tr, W1, b1, W2, b2)

    # ---- backpropagation: the chain rule, one layer at a time ----
    # dL/dz2 for binary cross-entropy with a sigmoid output.
    dz2 = (a2 - y_tr) / len(X_tr)
    dW2 = a1.T @ dz2
    db2 = dz2.sum(axis=0)

    da1 = dz2 @ W2.T
    dz1 = da1 * (z1 > 0)                # the ReLU derivative
    dW1 = X_tr.T @ dz1
    db1 = dz1.sum(axis=0)

    # ---- the update ---------------------------------------------
    W2 -= LEARNING_RATE * dW2
    b2 -= LEARNING_RATE * db2
    W1 -= LEARNING_RATE * dW1
    b1 -= LEARNING_RATE * db1

    if epoch % 250 == 0 or epoch == 1:
        train_loss = loss(a2, y_tr)
        test_loss = loss(forward(X_te, W1, b1, W2, b2)[3], y_te)
        accuracy = float(((forward(X_te, W1, b1, W2, b2)[3] > 0.5) == y_te).mean())
        history.append((epoch, train_loss, test_loss, accuracy))

print(f'{"epoch":>7} {"train loss":>11} {"test loss":>10} {"test acc":>9}')
for epoch, train_loss, test_loss, accuracy in history:
    print(f'{epoch:>7} {train_loss:>11.4f} {test_loss:>10.4f} {accuracy:>9.3f}')
print()

# What the hidden layer learned: each unit is a half-plane.
print('hidden layer weights (each column is one unit):')
print(np.round(W1, 2))
print()
print('Each hidden unit learned a line through the input space. The')
print('output layer combines them into the XOR pattern, which no')
print('single line could ever produce.')

Open this example in the editor and change it. Nothing you type is saved.

Four pieces in that loop, and every framework has the same four:

  1. Forward - compute the prediction.
  2. Loss - compare it with the truth.
  3. Backward - compute how much each weight contributed to the error.
  4. Update - move each weight a small step against its gradient.

Initialisation is not a detail

PY Example 7 30 lines
import numpy as np
import pandas as pd

rng = np.random.default_rng(17)

def propagate(scale, layers=12, width=200, seed=0):
    """Push a signal through many layers and watch its size."""
    generator = np.random.default_rng(seed)
    activation = generator.normal(0, 1, (500, width))
    sizes = []
    for _ in range(layers):
        W = generator.normal(0, scale, (width, width))
        activation = np.maximum(0, activation @ W)
        sizes.append(float(activation.std()))
    return sizes

rows = []
for name, scale in [('too small (0.01)', 0.01),
                    ('too large (0.20)', 0.20),
                    ('He (sqrt(2/n))', np.sqrt(2 / 200))]:
    sizes = propagate(scale)
    rows.append({'initialisation': name,
                 'layer 1 std': round(sizes[0], 4),
                 'layer 6 std': round(sizes[5], 4),
                 'layer 12 std': round(sizes[11], 6)})
print(pd.DataFrame(rows).to_string(index=False))
print()
print('Too small and the signal vanishes by layer 12; too large and it')
print('explodes. He initialisation keeps it roughly constant, which is')
print('what makes a deep network trainable at all.')

Open this example in the editor and change it. Nothing you type is saved.

That is why sqrt(2 / fan_in) appears in every framework's default. It is not a magic number: it is the scale that keeps the variance of the activations constant through a ReLU layer.

Width, depth and the cost

PY Example 8 18 lines
import numpy as np
import pandas as pd

def parameters(shape):
    return sum(a * b + b for a, b in zip(shape[:-1], shape[1:]))

rows = []
for shape in [(100, 64, 1), (100, 256, 1), (100, 64, 64, 1),
              (100, 256, 256, 1), (100, 512, 512, 512, 1)]:
    count = parameters(shape)
    rows.append({'architecture': ' -> '.join(map(str, shape)),
                 'parameters': f'{count:,}',
                 'memory (float32)': f'{count * 4 / 1024:,.0f} KiB'})
print(pd.DataFrame(rows).to_string(index=False))
print()
print('Parameters grow with the PRODUCT of adjacent layer widths, so')
print('doubling the width roughly quadruples the layer. Depth is the')
print('cheaper way to add capacity.')

Open this example in the editor and change it. Nothing you type is saved.

A practical starting point for tabular data, when a neural network is the right choice at all: two or three hidden layers, each between 32 and 256 units wide, ReLU activations, and a sigmoid or softmax output. Anything larger needs a reason.

When it goes wrong

Symptom Cause
A deep network behaves like a linear one Missing activation functions
Many units output zero for everything Dead ReLUs; use leaky ReLU or GELU
Activations vanish or explode with depth Bad initialisation; use He or Glorot
A wide shallow network underperforms The problem needs composition, so add depth
The loss does not move at all Learning rate too small, or a bug in the gradient
Parameters exploded with one change Width grows the layer quadratically
The network beats a tree by nothing Tabular data; the tree is probably right
Training is unstable from the first step Unscaled inputs

A check you can run

Take the numpy network above and delete np.maximum(0, z1), replacing it with z1.

The network becomes two linear layers, which is one linear layer, and the XOR accuracy falls to about 0.5 - no better than a coin. One function, removed, and a network that cannot learn a pattern a child can draw. That is the clearest demonstration there is of what the non-linearity is for.