Skip to content
academia.sh

Lesson 05 / 24

Backpropagation

Applying the chain rule by hand over the network and verifying the resulting gradient with the numerical derivative: across all 113 parameters the largest absolute difference is 2.193e-10 at initialization and 6.441e-11 after a hundred epochs of training. When the step size is chosen as 0.001, the difference in the trained network jumps to 2.518e-01; this is not a calculation error, it is the kink of the rectified unit. The cost: backpropagation produces all 113 gradients with one forward and one backward pass per example, the numerical derivative asks for 4,520 passes for the same job, and the ratio grows with the parameter count: 58, 226, 898. The triple does not change — baseline 0.7579, network number 0.7857, second run 0.7698.

Published Updated

In this lesson

  • Applies the chain rule over the network layer by layer and writes the weight gradient as the product of the incoming value and the layer input
  • Verifies the hand-computed gradient with the central difference and reports the difference between the two methods by step size
  • Compares the number of passes of backpropagation and of the numerical derivative and shows that the ratio grows with the parameter count
Contents

In four lessons the gradient always arrived ready-made. How the number at the output layer is found was written in 04, but how that number is distributed over the weights of the hidden layer was never opened. This lesson writes that distribution and tests what it wrote with a separate method.

The rule of the distribution is the chain rule. The effect of a weight on the loss is the product of two parts: the value coming back to the unit it feeds and the input it multiplies. The value coming back is also a product — the sum of what comes from the next layer weighted by the weights, times the derivative of the activation. When a single rule is applied layer by layer, the gradients of all parameters come out.

  • NN29. The set, the split, the seed, the twelve columns and the training set reduced to 300 rows are as in 01–04; the network is 12–8–1, 113 parameters. The baseline on the test set is 0.7579.
  • NN30. The loss is cross-entropy. The value arriving at the output layer is a−ya-y, because the derivative of the sigmoid cancels against the derivative of the loss; this was shown in 04.
  • NN31. The activation of the hidden layer is the rectified linear unit. Its derivative is 1 when the input is positive and 0 otherwise; it is undefined at zero and 0 is chosen in the code.
  • NN32. The gradients are summed over the examples. Because the numerical derivative is computed over the mean loss, its result is multiplied by the number of examples and the two methods come to the same scale.
  • NN33. The numerical derivative is the central difference: the parameter is nudged once up and once down and the difference is divided by two steps. It is more accurate than the one-sided difference and asks for twice the passes.
  • NN34. The verification is made on a subset of 20 rows; the reason is the number of passes.
  • NN35. The number of passes is in terms of forward passes. The backward pass of backpropagation is of the same order as a forward pass and is not counted separately; the ratio is for this reason a lower bound.

The Chain Rule over the Network

Backpropagation consists of two runs. In the forward pass the intermediate values are stored, because the weight gradient needs them. In the backward pass one starts from the output and does two jobs at every layer: the gradients of that layer are written, then the value coming back is carried to the previous layer.

# backpropagation.py — MODEL: the same FICTIONAL subscriber table, the same seed and the same split;
# the training set is reduced to 300 rows as in 01.
import math
import statistics

SEED, RAW, M32 = 20260218, 1400, 0xFFFFFFFF
REGION = [("north", 0.28, 21, 0.00), ("south", 0.22, 17, -0.10),
          ("east", 0.18, 26, 0.30), ("west", 0.14, 14, -0.05),
          ("central", 0.18, 23, 0.05)]


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


SUBSCRIBERS, DATA = [], []
for i in range(RAW):
    r = rng(SEED + i)
    b = REGION[pick(r(), [x[1] for x in REGION])]
    household = 1 + pick(r(), [0.06, 0.24, 0.30, 0.24, 0.11, 0.05])
    rating = 1 + pick(r(), [0.08, 0.14, 0.27, 0.34, 0.17])
    if r() >= 0.046:
        SUBSCRIBERS.append({"id": 10001 + i, "region": b, "household": household,
                            "satisfaction": rating})
for k in SUBSCRIBERS:
    r, v = rng(SEED + 7000 + k["id"]), []
    for n in range(pick(r(), [0.05, 0.12, 0.21, 0.62])):
        v.append(0.0 if r() < 0.038 else math.floor(
            k["region"][2] * math.exp((r() + r() + r() - 1.5) * 0.62)
            * (1 - n * 0.05) * 100 + 0.5) / 100)
        r()
    if not v:
        continue
    mean, vol, r = sum(v) / len(v), max(v) - min(v), rng(SEED + 51000 + k["id"])
    z = (0.052 * (mean - 20) + 0.85 * (min(v) == 0.0) + 0.026 * vol
         + 0.24 * (k["household"] >= 5) - 0.20 * (k["satisfaction"] >= 4) + k["region"][3]
         + 0.9 * (k["id"] - 10001) / 1399 + (r() + r() + r() - 1.5) * 1.30)
    x = {"household": k["household"], "satisfaction": k["satisfaction"], "period": len(v),
         "mean_consumption": round(mean, 2), "volatility": round(vol, 2),
         "zero_readings": int(min(v) == 0.0), "suspicious": int(z > 1.35)}
    x["per_person"] = round(x["mean_consumption"] / x["household"], 3)
    for b in REGION:
        x["area_" + b[0]] = int(k["region"][0] == b[0])
    DATA.append(x)


def shuffle(data, seed):
    r, s = rng(seed), list(range(len(data)))
    for i in range(len(s) - 1, 0, -1):
        j = int(r() * (i + 1))
        s[i], s[j] = s[j], s[i]
    return [data[i] for i in s]


K = shuffle(DATA, SEED + 90000)
TRAIN, VAL, TEST = K[:756], K[756:1008], K[1008:]
FIELDS = ["mean_consumption", "volatility", "household", "satisfaction", "period",
          "zero_readings", "per_person", "area_north", "area_south", "area_east",
          "area_west", "area_central"]
MEAN = {a: statistics.fmean(x[a] for x in TRAIN) for a in FIELDS}
STD = {a: max(1e-9, statistics.pstdev([x[a] for x in TRAIN])) for a in FIELDS}
BASELINE = sum(x["suspicious"] == 0 for x in TEST) / len(TEST)


def make_set(data):
    return ([[(x[a] - MEAN[a]) / STD[a] for a in FIELDS] for x in data],
            [float(x["suspicious"]) for x in data])


X_TR, Y_TR = make_set(TRAIN[:300])          # REDUCED training set
X_VA, Y_VA = make_set(VAL)
X_TE, Y_TE = make_set(TEST)


def squash(z):                              # S-shaped function, overflow-protected
    if z >= 0:
        return 1.0 / (1.0 + math.exp(-z)) if z < 700 else 1.0
    e = math.exp(z) if z > -700 else 0.0
    return e / (1.0 + e)


def rectified(z):
    return z if z > 0.0 else 0.0


def init_weights(seed, sizes):              # gain-scaled initialization, bias zero
    r, net = rng(seed), []
    for a, b in zip(sizes, sizes[1:]):
        scale = math.sqrt(2.0 / a)
        net.append(([[(r() * 2 - 1) * scale for _ in range(a)] for _ in range(b)], [0.0] * b))
    return net


def matmul(W, bias, a):                     # MATRIX MULTIPLICATION by hand: each row is a unit
    return [sum(w * v for w, v in zip(row, a)) + b
            for row, b in zip(W, bias)]


def forward(net, x):                        # FORWARD PASS: layer by layer
    a = x
    for k, (W, bias) in enumerate(net):
        z = matmul(W, bias, a)
        a = [squash(z[0])] if k == len(net) - 1 else [rectified(t) for t in z]
    return a


def loss(a, y):                             # cross-entropy
    return -(math.log(max(a, 1e-12)) if y else math.log(max(1 - a, 1e-12)))


def backpropagation(net, X, Y, trace=False):    # MODEL: chain rule layer by layer
    gW = [[[0.0] * len(W[0]) for _ in W] for W, _ in net]
    gb = [[0.0] * len(bias) for _, bias in net]
    total = 0.0
    for i in range(len(X)):
        a, acts = X[i], [X[i]]              # FORWARD PASS: intermediate values are stored
        for k, (W, bias) in enumerate(net):
            z = matmul(W, bias, a)
            a = [squash(z[0])] if k == len(net) - 1 else [rectified(q) for q in z]
            acts.append(a)
        total += loss(acts[-1][0], Y[i])
        d = [acts[-1][0] - Y[i]]            # output layer: loss and sigmoid cancel
        for k in range(len(net) - 1, -1, -1):
            W, a = net[k][0], acts[k]       # a: the INPUT of this layer
            if trace:
                print(f"  layer {k + 1}: incoming width {len(d)}, first two "
                      f"{[round(v, 5) for v in d[:2]]}")
            for j, dj in enumerate(d):      # weight gradient = incoming x input
                s = gW[k][j]
                for p in range(len(a)):
                    s[p] += dj * a[p]
                gb[k][j] += dj              # bias gradient = incoming
            if k:                           # carry to the previous layer, with the activation derivative
                prev = [0.0] * len(a)
                for j, dj in enumerate(d):
                    s = W[j]
                    for p in range(len(a)):
                        prev[p] += dj * s[p]
                d = [prev[p] if a[p] > 0 else 0.0 for p in range(len(a))]
    return gW, gb, total / len(X)


net = init_weights(SEED + 1, [12, 8, 1])
print(f"single subscriber, true label {int(Y_TR[0])}, network output {forward(net, X_TR[0])[0]:.5f}")
gW, gb, k0 = backpropagation(net, X_TR[:1], Y_TR[:1], trace=True)
print(f"  loss {k0:.5f}")
print(f"  output layer weight gradient, first three "
      f"{[round(v, 5) for v in gW[1][0][:3]]}")
print(f"  hidden layer unit 1 weight gradient, first three "
      f"{[round(v, 5) for v in gW[0][0][:3]]}")
print(f"  hidden layer bias gradients, first three {[round(v, 5) for v in gb[0][:3]]}")
single subscriber, true label 0, network output 0.49369
  layer 2: incoming width 1, first two [0.49369]
  layer 1: incoming width 8, first two [0.0, 0.00155]
  loss 0.68061
  output layer weight gradient, first three [0.0, 0.15433, 0.18243]
  hidden layer unit 1 weight gradient, first three [0.0, 0.0, 0.0]
  hidden layer bias gradients, first three [0.0, 0.00155, -0.03503]

The value arriving at the output layer is 0.49369, that is, the output of the network minus the true label. Eight values go down to the hidden layer and the first is exactly 0.0. The reason had been measured in 02: that unit is not active for this subscriber, the rectified function has cut its input, its derivative is 0 and the chain breaks there. The result shows in the last two lines — the twelve weight gradients of the first unit are zero and so is its bias gradient. This subscriber cannot teach that unit anything.

Verification with the Numerical Derivative

A derivative written by hand can be silently wrong: the code runs, the loss falls, numbers are produced, and the error shows itself only as performance staying a little low. The independent test is to measure the loss twice with a small nudge of the parameter and take the difference. It is slow, but because it knows nothing about the structure of the network, it does not make the same error as the chain rule.

def mean_loss(net, X, Y):
    return sum(loss(forward(net, x)[0], y) for x, y in zip(X, Y)) / len(X)


def numerical_derivative(net, X, Y, eps):   # central difference, two passes per parameter
    gW = [[[0.0] * len(W[0]) for _ in W] for W, _ in net]
    gb = [[0.0] * len(bias) for _, bias in net]
    passes = 0
    for k, (W, bias) in enumerate(net):
        for j in range(len(W)):
            for p in range(len(W[j]) + 1):
                target, index = (W[j], p) if p < len(W[j]) else (bias, j)
                old = target[index]
                target[index] = old + eps
                plus = mean_loss(net, X, Y)
                target[index] = old - eps
                minus = mean_loss(net, X, Y)
                target[index] = old
                value = (plus - minus) / (2 * eps) * len(X)
                passes += 2 * len(X)
                if p < len(W[j]):
                    gW[k][j][p] = value
                else:
                    gb[k][j] = value
    return gW, gb, passes


def check(net, X, Y, eps):                  # hand-written gradient against the numerical derivative
    aW, ab, _ = backpropagation(net, X, Y)
    nW, nb, passes = numerical_derivative(net, X, Y, eps)
    diffs = []
    for k in range(len(net)):
        for j in range(len(aW[k])):
            for p in range(len(aW[k][j])):
                diffs.append((abs(aW[k][j][p] - nW[k][j][p]), abs(aW[k][j][p])))
            diffs.append((abs(ab[k][j] - nb[k][j]), abs(ab[k][j])))
    return len(diffs), max(a for a, _ in diffs), max(a / max(b, 1e-12) for a, b in diffs), passes


def train_model(net, X, Y, rate, epochs):   # MODEL: full-batch gradient descent
    n = len(X)
    for _ in range(epochs):
        gW, gb, _ = backpropagation(net, X, Y)
        for k, (W, bias) in enumerate(net):
            for j in range(len(W)):
                s, g = W[j], gW[k][j]
                for p in range(len(s)):
                    s[p] -= rate * g[p] / n
                bias[j] -= rate * gb[k][j] / n
    return net


SUB_X, SUB_Y = X_TR[:20], Y_TR[:20]         # verification subset, 20 rows
TRAINED = train_model(init_weights(SEED + 1, [12, 8, 1]), X_TR, Y_TR, 0.3, 100)
print(f"{'network':<24} {'eps':>7} {'params':>10} {'max absolute':>16} "
      f"{'max relative':>15}")
for label, net in (("at initialization", init_weights(SEED + 1, [12, 8, 1])),
                   ("after 100 epochs", TRAINED)):
    for eps in (1e-3, 1e-5, 1e-7):
        count, max_abs, max_rel, passes = check(net, SUB_X, SUB_Y, eps)
        print(f"{label:<24} {eps:>7g} {count:>10} {max_abs:>16.3e} {max_rel:>15.3e}")
network                      eps     params     max absolute    max relative
at initialization          0.001        113        2.199e-07       1.583e-07
at initialization          1e-05        113        2.193e-10       4.567e-08
at initialization          1e-07        113        2.591e-08       4.932e-06
after 100 epochs           0.001        113        2.518e-01       2.881e+00
after 100 epochs           1e-05        113        6.441e-11       1.227e-09
after 100 epochs           1e-07        113        5.694e-09       2.697e-07

The first three rows show the error of the numerical derivative itself. When the step is large the truncation error dominates, when it is small the rounding error; the best agreement is in the middle, at 1e-05, with 2.193e-10. That is, across all 113 parameters the two methods give the same number up to about ten significant digits. This is the proof that the hand-written chain rule is correct.

The fourth row is a trap and must not be passed over without measuring. In the trained network, when the step is chosen as 0.001, the largest absolute difference jumps to 2.518e-01 and the relative difference to 2.881. This is not a calculation error; the same network at the same step gives 6.441e-11 at 1e-05. The cause is the kink of the rectified unit: a nudge of 0.001 is enough to carry the weighted sum from one side of zero to the other, and the two ends of the central difference fall on two different linear pieces of the function. When a verification fails, the first place to look is not the code but the step size.

Cost: How Many Passes

If the two methods produce the same number, which one to use depends on the cost. Backpropagation produces the gradients of all parameters in one forward and one backward pass. The numerical derivative asks for two separate passes for each parameter.

print(f"{'units':>6} {'params':>10} {'backpropagation':>15} {'numerical derivative':>20} "
      f"{'ratio':>7}")
for h in (2, 8, 32):
    sizes = [12, h, 1]
    P = sum(a * b + b for a, b in zip(sizes, sizes[1:]))
    print(f"{h:>6} {P:>10} {len(SUB_X):>15} {2 * P * len(SUB_X):>20} {2 * P:>7}")

print(f"\n{'run':>6} {'train_loss':>10} {'validation':>10} {'test':>8}")
TEST_ACCS = []
for run in (1, 2):
    net = train_model(init_weights(SEED + run, [12, 8, 1]), X_TR, Y_TR, 0.3, 100)
    _, _, loss_value = backpropagation(net, X_TR, Y_TR)
    val_acc = sum((forward(net, x)[0] > 0.5) == (y > 0.5) for x, y in zip(X_VA, Y_VA)) / len(X_VA)
    test_acc = sum((forward(net, x)[0] > 0.5) == (y > 0.5) for x, y in zip(X_TE, Y_TE)) / len(X_TE)
    TEST_ACCS.append(test_acc)
    print(f"{run:>6} {loss_value:>10.4f} {val_acc:>10.4f} {test_acc:>8.4f}")
print(f"baseline {BASELINE:.4f}, network number {TEST_ACCS[0]:.4f}, "
      f"second run {TEST_ACCS[1]:.4f}, difference "
      f"{round(abs(TEST_ACCS[0] - TEST_ACCS[1]) * len(X_TE))} subscribers")
 units     params backpropagation numerical derivative   ratio
     2         29              20                 1160      58
     8        113              20                 4520     226
    32        449              20                17960     898

   run train_loss validation     test
     1     0.3597     0.8214   0.7857
     2     0.3646     0.8056   0.7698
baseline 0.7579, network number 0.7857, second run 0.7698, difference 4 subscribers

The backpropagation column is 20 in all three rows — as many as the number of examples. The numerical derivative column grows together with the parameter count: 1,160, 4,520, 17,960. The ratio is twice the parameter count and rises to 898 in the network with 32 units. The cost of backpropagation is independent of the parameter count; the work is done by the intermediate values stored in the forward pass. For this reason the numerical derivative is not a training method but a tool of inspection.

The last row confirms the number of the four lessons. In this section the training was carried out with backpropagation and the triple is the same output as in 01: 0.7579 — 0.7857 — 0.7698. The reason is plain; the code running since 01 was already backpropagation, only it had not been named.

Summary

  • The gradient of a weight is the product of the value coming back to the unit it feeds and the input that weight multiplies; the value coming back is carried from the next layer and multiplied by the derivative of the activation.
  • All gradients of an inactive unit are zero: in the example subscriber, the twelve weight gradients and the bias gradient of the first unit of the hidden layer all come out as 0.0.
  • Verification with the central difference agrees in all 113 parameters: the largest absolute difference is 2.193e-10 at initialization and 6.441e-11 after a hundred epochs of training. When the step is chosen too large or too small, the error of the numerical derivative itself grows.
  • In the trained network the difference jumps to 2.518e-01 at step 0.001; this is the kink of the rectified unit, not a calculation error. The same network gives 6.441e-11 at 1e-05.
  • Backpropagation produces all 113 gradients with one forward and one backward pass per example; the numerical derivative asks for 4,520 passes for the same job and the ratio grows with the parameter count: 58, 226, 898.

Next Step

The gradient is now computed correctly and cheaply. But what to do with it was never asked in five lessons: in every epoch the gradient was multiplied by the same constant number and subtracted from the weights, always of the same size, without ever looking at what the previous epochs had done. Using the same gradient in another way — remembering the direction of the previous steps or giving each parameter its own step size — what loss it reaches in the same epoch budget and how much it moves between two runs was not measured. The next lesson treats the gradient not as a result but as an input.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close