Index Ai Academy
Phase 4 · Deep learning · Architectures

Residual connections

Skip connections that let gradients flow.

About 30 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

In the vanishing-gradients lesson, you saw why deep networks were so hard to train. In 2015, a team trained networks with more than 150 layers by making one small change. Each block adds its output to its input instead of replacing it. That change is called a residual connection, or skip connection, and it's now everywhere. Every block in every large language model has two of them. The "residual stream" they create is how researchers describe what flows through a language model.

What you'll be able to do

  • Write a residual block, y = x + f(x), and explain the identity path
  • Show why it keeps gradients flowing through very deep stacks
  • See why residual connections need normalization or scaled initialization
  • Read the transformer's 'residual stream' in these terms

What it covers

  1. Add, don't replace
  2. Worked example: a tiny residual block by hand
  3. A clear path for the gradient
  4. Twenty layers, three ways
  5. Keeping the sum in check
  6. The transformer's residual stream

Add, don't replace

Picture a dish passed down a line of 20 cooks. In one kitchen, each cook tastes the dish, throws it away, and cooks a new one from memory. By the end, little of the first dish is left. In another kitchen, each cook keeps the dish and adds a pinch of something. Nothing is lost, and a cook with nothing to add can pass the dish on as it is.

A normal layer works like the first kitchen. It takes its input, xx, and returns something new. The input itself isn't passed on. Call the new thing f(x)f(x), where ff stands for whatever the layer computes.

A residual block works like the second kitchen. It adds the layer's output back onto its input:

y=x+f(x)y = x + f(x)

In plain words: the output yy is the input, plus a change worked out by the layer. The layer only has to learn the change, not rebuild the whole input.

That change is called the residual. The part of the block that computes it is the branch. The direct route that carries xx past the branch is the skip connection, also called the identity path. "Identity" means "unchanged".

Here's a small example. Say the input is 2, 5 and 1, and the branch works out 10% of each number: 0.2, 0.5 and 0.1. Add each pair together and the output is 2.2, 5.5 and 1.1.

x = [2.0, 5.0, 1.0]

def f(v):                       # a stand-in for the block's layer
    return [0.1 * a for a in v]

y = [a + u for a, u in zip(x, f(x))]    # the residual block: x + f(x)
print(y)
It runs right here in your browser, with nothing to install.

This prints [2.2, 5.5, 1.1].

Doing nothing is easy for a residual block. If the branch outputs all zeros, the block passes its input straight through. A normal layer would have to learn its weights very precisely to copy its input. A residual block gets that for free.

Quick check
In a residual block y = x + f(x), what happens if f learns to output all zeros?
  1. The block outputs zeros
  2. Training stops
  3. The gradient becomes zero
  4. The block passes its input through unchanged
Show the answer

The block passes its input through unchanged

y = x + 0 = x. Doing nothing is easy for a residual block, which is part of why adding more blocks is safe.

Worked example

Worked example: a tiny residual block by hand

This block works on a list of two numbers. Its branch is a tiny linear layer (a weight table W, no bias) followed by ReLU. The code sends [2.0, 1.0] through the same block three times. Then it zeroes the weights to see what an empty branch does.

def relu(v):
    return [max(0.0, a) for a in v]

def layer(W, v):                       # a tiny linear layer, no bias
    return [sum(w * a for w, a in zip(row, v)) for row in W]

def residual_block(W, x):
    update = relu(layer(W, x))         # the branch: f(x)
    return [a + u for a, u in zip(x, update)]   # the skip: x + f(x)

W = [[0.5, -1.0],
     [0.25, 0.5]]
x = [2.0, 1.0]
for block in range(3):
    x = residual_block(W, x)
    print("after block", block + 1, x)

W_zero = [[0.0, 0.0], [0.0, 0.0]]
print("zero branch:", residual_block(W_zero, [2.0, 1.0]))
In the lesson you can change this and run it.

Follow the first block by hand. The input is 2 and 1.

  • Row one of W: 0.5 × 2 + (−1) × 1 = 0. ReLU keeps 0.
  • Row two: 0.25 × 2 + 0.5 × 1 = 1. ReLU keeps 1.

So the branch's update is 0 and 1. Add it to the input and you get 2 and 2.

Run it again and the second number goes 2, then 3.5, then 5.75. Every block adds a bit more on top. Hold on to that: in a deep stack, this steady adding can grow out of control. A later section shows how real networks stop it.

The last line zeroes every weight. The branch then adds nothing, and the block returns its input, [2.0, 1.0], unchanged.

A clear path for the gradient

In the vanishing-gradients lesson, the gradient traveled backward through the network. Each layer multiplied it by its own factor. Factors below 1, multiplied many times, made it vanish.

A residual block changes that factor. Two symbols first. yy is the block's output and xx is its input. ∂y∂x\frac{\partial y}{\partial x} is the block's factor: how much yy changes when you nudge xx a little.

∂y∂x=1+∂f∂x\frac{\partial y}{\partial x} = 1 + \frac{\partial f}{\partial x}

In plain words: the factor for a residual block is 1, plus the branch's own factor. The 1 comes from the skip connection, which passes xx through unchanged.

So a stack of plain layers multiplies factors like "something". A stack of residual blocks multiplies factors like "1 + something". Even if every branch passes back a tiny gradient, the 1 carries the signal through.

Here's a small case. Each branch has a factor of 0.05. Twenty plain layers give 0.05200.05^{20} (0.05 multiplied by itself 20 times), about 10−2610^{-26}. That's far too small to learn from. Twenty residual blocks give 1.05201.05^{20}, about 2.65. The gradient is still a usable size.

plain, residual = 1.0, 1.0
for layer in range(20):
    plain = plain * 0.05              # plain layer: only the branch's factor
    residual = residual * (1 + 0.05)  # residual block: 1 + the branch's factor
print(f"plain    {plain:.1e}")
print(f"residual {residual:.2f}")

This prints plain 9.5e-27 and residual 2.65.

Note: this is a simplification. In a real network, each factor is a whole table of numbers, not one number. The single-number version still shows the main effect.

Go deeper Where the 1 comes from

The block computes y=x+f(x)y = x + f(x), a sum of two parts. When you nudge xx, each part changes, and the changes add up.

  • The first part is xx itself. Nudge xx by 0.01 and it changes by 0.01. Its factor is 1.
  • The second part is f(x)f(x). Its factor is the branch's own slope, ∂f∂x\frac{\partial f}{\partial x}.

Add the two and you get 1+∂f∂x1 + \frac{\partial f}{\partial x}. In backpropagation, the gradient arriving at yy is copied down both routes and the two results are added.

Try it now

Thirty plain layers lose almost all the gradient. With skip connections, it arrives intact.

Quick check
Three layers each have a branch factor of 0.1. Predict what this prints: the residual product first, then the plain one.
print(round((1 + 0.1) ** 3, 3), round(0.1 ** 3, 3))
  1. 1.1 0.1
  2. 0.001 1.331
  3. 1.331 0.001
  4. 1.3 0.3
Show the answer

1.331 0.001

Residual blocks multiply 1.1 × 1.1 × 1.1 = 1.331. Plain layers multiply 0.1 × 0.1 × 0.1 = 0.001. Three plain layers already shrink the gradient a thousand times.

Twenty layers, three ways

Now try it on a real network. The code below trains three networks on the handwritten digits. Each one has 20 hidden layers. Each trains for three epochs (passes over the data) with mini-batch SGD.

  • plain: a normal stack of 20 layers.
  • residual: the same layers, each wrapped as a residual block.
  • residual + pre-norm: residual blocks with a LayerNorm at the start of each branch. LayerNorm, from the normalization lesson, rescales a list of numbers to a standard size. This placement is called pre-norm, and modern transformers use it.
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split

digits = load_digits()
X = torch.tensor(digits.data / 16.0, dtype=torch.float32)
y = torch.tensor(digits.target)
X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.2, random_state=0)

class PlainBlock(nn.Module):
    def __init__(self, width):
        super().__init__()
        self.lin = nn.Linear(width, width)
    def forward(self, x):
        return torch.relu(self.lin(x))

class ResidualBlock(nn.Module):
    def __init__(self, width, prenorm=False):
        super().__init__()
        self.norm = nn.LayerNorm(width) if prenorm else nn.Identity()
        self.lin = nn.Linear(width, width)
    def forward(self, x):
        return x + torch.relu(self.lin(self.norm(x)))      # the skip connection

loss_fn = nn.CrossEntropyLoss()
for name, make_block in [("plain", lambda: PlainBlock(32)),
                         ("residual", lambda: ResidualBlock(32)),
                         ("residual + pre-norm", lambda: ResidualBlock(32, prenorm=True))]:
    torch.manual_seed(0)
    model = nn.Sequential(nn.Linear(64, 32), *[make_block() for _ in range(20)], nn.Linear(32, 10))
    opt = torch.optim.SGD(model.parameters(), lr=0.05)
    for epoch in range(3):
        for xb, yb in DataLoader(TensorDataset(X_train, y_train), batch_size=64, shuffle=True):
            opt.zero_grad()
            loss_fn(model(xb), yb).backward()
            opt.step()
    with torch.no_grad():
        loss = loss_fn(model(X_train), y_train).item()
        acc = (model(X_val).argmax(1) == y_val).float().mean().item()
    print(f"{name:<20} training loss {loss:.3f}   val accuracy {acc:.3f}")

(As always here, torch is the course's in-browser PyTorch subset.)

Read the three lines one at a time.

  • Plain never gets started. Its gradients vanish, and its loss stays at about 2.3. That's the loss you get from guessing evenly among the 10 digits. (It equals ln 10, the natural log of 10.)
  • Residual fixes the vanishing, but goes wrong the other way. Every block adds to the signal, so the numbers grow layer after layer until training blows up. The result is nan, short for "not a number".
  • Residual + pre-norm learns. The LayerNorm in each branch keeps every block's addition a sensible size. The 20-layer network reaches about 67% after only three epochs, and it's still improving.

Keeping the sum in check

You saw the growth in the worked example: each block added a bit more. Large language models use two standard ways to stop it. Both are common, and many models use both.

Normalize inside the branch

This is pre-norm. The block becomes:

x+f(norm(x))x + f(\text{norm}(x))

In plain words: standardize a copy of the input, run the copy through the branch, then add the result back onto the original xx. The branch always sees well-scaled numbers, however big the running total has become.

Here's a small example. Start with 1 and 3, and let the branch be "halve every number". Without the norm, each block adds half of the current total, so the total grows by half every time. After 20 blocks that's 1.5201.5^{20}, about 3,325 times bigger. With pre-norm, the branch always sees −1-1 and 1, so each block adds the same −0.5-0.5 and 0.5. After 20 blocks the numbers have moved by only 10 each.

import math

def norm(v):                    # standardize: mean 0, spread 1
    mu = sum(v) / len(v)
    sd = math.sqrt(sum((a - mu) ** 2 for a in v) / len(v))
    return [(a - mu) / sd for a in v]

def f(v):                       # a stand-in branch: halve every number
    return [0.5 * a for a in v]

no_norm = [1.0, 3.0]
pre_norm = [1.0, 3.0]
for block in range(20):
    no_norm = [a + b for a, b in zip(no_norm, f(no_norm))]          # x + f(x)
    pre_norm = [a + b for a, b in zip(pre_norm, f(norm(pre_norm)))]  # x + f(norm(x))
print("no norm: ", [round(v, 1) for v in no_norm])
print("pre-norm:", [round(v, 1) for v in pre_norm])

This prints [3325.3, 9975.8] without the norm and [-9.0, 13.0] with it.

Start the branches small

The second fix is about the starting weights. Set the last layer of each branch close to zero. At the start, every block then does almost nothing, and the deep network behaves like a shallow one. It "grows into" its depth as training goes on.

GPT-2 does this with a rule. It scales those starting weights by 1/N1/\sqrt{N}, where NN is the number of residual layers.

In plain words: the more blocks you stack, the smaller each one's starting weights.

With 16 residual layers, 16=4\sqrt{16} = 4, so the weights are scaled by 1/4 = 0.25. With 48, they're scaled by about 0.144.

import math
for n in [16, 48]:
    print(n, "residual layers: scale by", round(1 / math.sqrt(n), 3))
Go deeper Why the square root?

At the start, each branch adds something close to random to the running total. Random additions partly cancel out. Add NN random steps, each of size 1, and the total ends up about N\sqrt{N} in size, not NN. So shrinking each step by 1/N1/\sqrt{N} keeps the total near size 1, however deep the network is. For example, 16 random steps of size 1 add up to about

  1. Make each step 0.25 and they add up to about 1.

The transformer's residual stream

A transformer block is two residual blocks in a row. Here it is in two lines of Python-style code:

x = x + attention(norm1(x))     # tokens exchange information
x = x + mlp(norm2(x))           # each token is processed on its own

The first line lets each token look at the others. That's attention, which you'll meet in Phase 5. The second line runs a small MLP on each token by itself. Both lines are pre-norm residual blocks.

Now zoom out to the whole model. Each token has a list of numbers (a vector) that starts as the token's embedding. Every block reads from that vector and adds a small update to it. At the end, the final vector is turned into logits, the raw scores for each possible next token.

That running vector is called the residual stream. Researchers who study how LLMs work (interpretability researchers) largely do it by asking what each block writes into that stream.

For marketers: it's the difference between each editor rewriting a draft from scratch and each editor adding tracked changes to the same document. With tracked changes, nothing from earlier is lost, and a bad edit is easy to undo.

Quick check
Two blocks each add the update [0.5, -0.5] to a token's stream. Predict what this prints.
x = [1.0, 1.0]
for block in range(2):
    update = [0.5, -0.5]
    x = [a + u for a, u in zip(x, update)]
print(x)
  1. [1.5, 0.5]
  2. [2.0, 0.0]
  3. [1.0, 1.0]
  4. [0.5, -0.5]
Show the answer

[2.0, 0.0]

Each block adds its update on top of what's already there. After two blocks, the first number is 1 + 0.5 + 0.5 = 2.0 and the second is 1 − 0.5 − 0.5 = 0.0.

Practice what you just read

  • 7 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 4 review cards for spaced repetition

Create a free account

Try a quiz question

Why do residual connections help gradients reach early layers?

  1. Each block's backward factor is 1 + ∂f/∂x, so the identity path carries the gradient through
  2. They remove the activation functions, so no layer can shrink the gradient on its way back
  3. They make the network shallower, so the gradient has fewer layers to cross on the way back
  4. They increase the learning rate, so that the early layers get bigger updates from each step

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson