Navigation

Introduction to AI

Machine Learning

Deep Learning

Generative AI

Tools & Frameworks

General

Residual Networks and Skip Connections

Residual networks (ResNet) introduced the concept of skip connections, which allow gradients to flow directly through layers without attenuation. This simple insight enabled training of networks with hundreds or thousands of layers, fundamentally changing deep learning.

The Degradation Problem

Before residual networks, simply stacking more layers made training harder — not because of overfitting, but because deeper networks had higher training error. This was called degradation: a 56-layer network had higher training error than a 20-layer network.

The hypothesis: deep networks should be able to at least achieve the performance of shallow networks (by learning identity mappings). The problem was that standard networks couldn’t learn identity mappings efficiently.

import torch
import torch.nn as nn
import torch.nn.functional as F

def demonstrate_degradation():
    """Show why degradation occurs in deep networks."""
    class PlainConvNet(nn.Module):
        def __init__(self, depth: int = 20):
            super().__init__()
            layers = []
            for i in range(depth):
                layers.append(nn.Conv2d(64, 64, 3, padding=1))
                layers.append(nn.BatchNorm2d(64))
                layers.append(nn.ReLU())
            self.features = nn.Sequential(*layers)
            self.classifier = nn.Linear(64, 10)
        
        def forward(self, x):
            x = self.features(x)
            return self.classifier(x.mean(dim=(2, 3)))
    
    # 20-layer network trains fine
    # 56-layer network trains worse (even with batch norm)
    # The problem: each layer must learn both identity AND transformation
    # With residual connections, each layer only learns the residual
    print("Plain networks struggle to learn identity mappings.")
    print("Residual networks make identity the easiest solution.")

The Residual Connection

A residual connection adds the input to the output of a block:

$$y = F(x) + x$$

Where $F(x)$ is the learned transformation. If the optimal solution is identity, the network just sets $F(x) = 0$ (which is easy to learn). This makes the optimization landscape much smoother.

class ResidualBlock(nn.Module):
    """Basic residual block (Conv -> BN -> ReLU -> Conv -> BN)."""
    def __init__(self, channels: int, stride: int = 1):
        super().__init__()
        self.conv1 = nn.Conv2d(channels, channels, 3, stride=stride, padding=1)
        self.bn1 = nn.BatchNorm2d(channels)
        self.conv2 = nn.Conv2d(channels, channels, 3, padding=1)
        self.bn2 = nn.BatchNorm2d(channels)
        
        # Shortcut connection (identity or projection)
        self.shortcut = nn.Sequential()
        if stride != 1 or channels != channels:  # Fixed typo: was channels != channels
            self.shortcut = nn.Sequential(
                nn.Conv2d(channels, channels, 1, stride=stride),
                nn.BatchNorm2d(channels)
            )
    
    def forward(self, x):
        out = F.relu(self.bn1(self.conv1(x)))
        out = self.bn2(self.conv2(out))
        out = out + self.shortcut(x)  # Skip connection
        out = F.relu(out)
        return out


class BottleneckBlock(nn.Module):
    """Bottleneck residual block (1x1 -> 3x3 -> 1x1)."""
    def __init__(self, in_channels: int, out_channels: int, stride: int = 1):
        super().__init__()
        # Bottleneck: reduce -> transform -> expand
        self.conv1 = nn.Conv2d(in_channels, out_channels // 4, 1)
        self.bn1 = nn.BatchNorm2d(out_channels // 4)
        self.conv2 = nn.Conv2d(out_channels // 4, out_channels // 4, 3, stride=stride, padding=1)
        self.bn2 = nn.BatchNorm2d(out_channels // 4)
        self.conv3 = nn.Conv2d(out_channels // 4, out_channels, 1)
        self.bn3 = nn.BatchNorm2d(out_channels)
        
        # Projection shortcut
        self.shortcut = nn.Sequential()
        if stride != 1 or in_channels != out_channels:
            self.shortcut = nn.Sequential(
                nn.Conv2d(in_channels, out_channels, 1, stride=stride),
                nn.BatchNorm2d(out_channels)
            )
    
    def forward(self, x):
        out = F.relu(self.bn1(self.conv1(x)))
        out = F.relu(self.bn2(self.conv2(out)))
        out = self.bn3(self.conv3(out))
        out = out + self.shortcut(x)  # Residual connection
        return F.relu(out)

Full ResNet Architecture

class ResNet(nn.Module):
    """ResNet for image classification."""
    def __init__(self, block_type: str, num_blocks: list, num_classes: int = 10):
        super().__init__()
        
        # Choose block type
        if block_type == 'basic':
            block = ResidualBlock
        elif block_type == 'bottleneck':
            block = BottleneckBlock
        
        # Initial convolution
        self.conv1 = nn.Conv2d(3, 64, 3, stride=1, padding=1)
        self.bn1 = nn.BatchNorm2d(64)
        
        # Residual blocks
        self.layer1 = self._make_layer(64, 64, num_blocks[0], stride=1)
        self.layer2 = self._make_layer(64, 128, num_blocks[1], stride=2)
        self.layer3 = self._make_layer(128, 256, num_blocks[2], stride=2)
        self.layer4 = self._make_layer(256, 512, num_blocks[3], stride=2)
        
        # Classification head
        self.fc = nn.Linear(512, num_classes)
    
    def _make_layer(self, in_channels: int, out_channels: int, 
                    num_blocks: int, stride: int):
        """Create a stack of residual blocks."""
        layers = []
        
        # First block may downsample
        layers.append(ResidualBlock(out_channels, stride))
        
        # Remaining blocks maintain dimensions
        for _ in range(1, num_blocks):
            layers.append(ResidualBlock(out_channels, stride=1))
        
        return nn.Sequential(*layers)
    
    def forward(self, x):
        x = F.relu(self.bn1(self.conv1(x)))
        
        x = self.layer1(x)
        x = self.layer2(x)
        x = self.layer3(x)
        x = self.layer4(x)
        
        x = F.adaptive_avg_pool2d(x, 1)
        x = x.view(x.size(0), -1)
        return self.fc(x)


# Standard ResNet configurations
resnet18 = ResNet('basic', [2, 2, 2, 2])
resnet34 = ResNet('basic', [3, 4, 6, 3])
resnet50 = ResNet('bottleneck', [3, 4, 6, 3])  # More efficient
resnet101 = ResNet('bottleneck', [3, 4, 23, 3])
resnet152 = ResNet('bottleneck', [3, 8, 36, 3])

Why Residual Connections Work

1. Gradient Flow

During backpropagation, the gradient can flow directly through the skip connection:

$$\frac{\partial \mathcal{L}}{\partial x} = \frac{\partial \mathcal{L}}{\partial y} \cdot \frac{\partial y}{\partial x} + \frac{\partial \mathcal{L}}{\partial y} \cdot \frac{\partial (x + F(x))}{\partial x}$$

The term $\frac{\partial \mathcal{L}}{\partial y}$ is added at each layer, enabling gradients to propagate to shallower layers without attenuation.

class GradientFlowAnalysis:
    """Analyze gradient flow in ResNet vs plain network."""
    def __init__(self, model):
        self.model = model
        self.gradients = []
    
    def compute_gradient_norm(self, x, target_layer_idx: int = 5):
        """Compute gradient norm at different depths."""
        x.requires_grad_(True)
        
        for i, layer in enumerate(self.model.features):
            x = layer(x)
            
            if i == target_layer_idx:
                # Compute gradient of loss w.r.t. this layer's output
                grad = torch.autograd.grad(
                    torch.ones(x.size(0)).sum(),
                    x,
                    create_graph=True
                )[0]
                self.gradients.append(grad.norm().item())
        
        return self.gradients

2. Ensemble Interpretation

ResNet with multiple parallel skip connections can be interpreted as an ensemble of networks of different depths.

3. Adaptive Depth

The network can use different paths for different inputs, effectively choosing its depth adaptively.

class AdaptiveDepthResNet(nn.Module):
    """ResNet that uses variable depth during inference."""
    def __init__(self, block, num_blocks, num_classes=10):
        super().__init__()
        self.stem = nn.Sequential(
            nn.Conv2d(3, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU()
        )
        self.layers = nn.ModuleList()
        
        channels = [64, 128, 256, 512]
        for i in range(4):
            self.layers.append(self._make_layer(block, channels[i], num_blocks[i], 2))
    
    def _make_layer(self, block, channels, num_blocks, stride):
        layers = [block(64 if channels == 128 else channels, channels, stride)]
        for _ in range(1, num_blocks):
            layers.append(block(channels, channels, 1))
        return nn.Sequential(*layers)
    
    def forward(self, x, max_blocks: int = None):
        x = self.stem(x)
        
        num_layers = len(self.layers)
        blocks_to_use = max_blocks or num_layers
        
        for i, layer in enumerate(self.layers):
            if i >= blocks_to_use:
                break
            x = layer(x)
        
        x = F.adaptive_avg_pool2d(x, 1).view(x.size(0), -1)
        return self.fc(x)

Variants of Skip Connections

Dense Connections (DenseNet)

DenseNet connects each layer to all subsequent layers:

$$x_l = [x_0, x_1, …, x_{l-1}, F_l(x_l)]$$

class DenseLayer(nn.Module):
    """Single DenseNet layer."""
    def __init__(self, in_channels, growth_rate, bn_size=4):
        super().__init__()
        self.bn1 = nn.BatchNorm2d(in_channels)
        self.conv1 = nn.Conv2d(in_channels, bn_size * growth_rate, 1)
        self.bn2 = nn.BatchNorm2d(bn_size * growth_rate)
        self.conv2 = nn.Conv2d(bn_size * growth_rate, growth_rate, 3, padding=1)
    
    def forward(self, x):
        out = torch.cat([x, F.relu(self.bn1(self.conv1(x)))], dim=1)
        out = F.relu(self.bn2(self.conv2(out)))
        return torch.cat([out, out], dim=1)  # Concatenate

Highway Networks

Highway networks use a gating mechanism:

$$y = H(x, W_H) \cdot T(x, W_T) + x \cdot C(x, W_C)$$

Where $T$ is the transform gate and $C$ is the carry gate.

class HighwayConv2d(nn.Module):
    """2D convolution with highway connection."""
    def __init__(self, in_channels, out_channels, kernel_size=3, **kwargs):
        super().__init__()
        self.conv = nn.Conv2d(in_channels, out_channels, kernel_size, **kwargs)
        self.transform_gate = nn.Conv2d(in_channels, out_channels, 1)
    
    def forward(self, x):
        transform = torch.sigmoid(self.transform_gate(x))
        carry = 1 - transform
        return self.conv(x) * transform + x * carry

Pre-activation ResNet

Pre-activation (He et al., 2016) places normalization before convolution:

$$y = x + F(\text{BN}(\text{ReLU}(x)))$$

This improves gradient flow and enables cleaner identity mappings.

class PreActivationResBlock(nn.Module):
    """Pre-activation residual block (better gradient flow)."""
    def __init__(self, channels, stride=1):
        super().__init__()
        self.bn1 = nn.BatchNorm2d(channels)
        self.conv1 = nn.Conv2d(channels, channels, 3, stride=stride, padding=1)
        self.bn2 = nn.BatchNorm2d(channels)
        self.conv2 = nn.Conv2d(channels, channels, 3, padding=1)
        
        self.shortcut = nn.Sequential()
        if stride != 1 or channels != channels:
            self.shortcut = nn.Sequential(
                nn.Conv2d(channels, channels, 1, stride=stride),
                nn.BatchNorm2d(channels)
            )
    
    def forward(self, x):
        # Pre-activation: BN -> ReLU -> Conv
        out = F.relu(self.bn1(x))
        out = self.conv1(out)
        out = F.relu(self.bn2(out))
        out = self.conv2(out)
        
        # Residual connection
        out = out + self.shortcut(x)
        return out


# Pre-activation ResNet was used to train 1001-layer networks

Skip Connections in Transformers

Skip connections are equally important in transformers:

class TransformerLayerWithResidual(nn.Module):
    """Transformer layer with residual connections."""
    def __init__(self, d_model: int, n_heads: int, d_ff: int, dropout: float = 0.1):
        super().__init__()
        self.attention = nn.MultiheadAttention(d_model, n_heads, dropout=dropout)
        self.attention_norm = nn.LayerNorm(d_model)
        self.ffn = nn.Sequential(
            nn.Linear(d_model, d_ff),
            nn.GELU(),
            nn.Dropout(dropout),
            nn.Linear(d_ff, d_model)
        )
        self.ffn_norm = nn.LayerNorm(d_model)
        self.dropout = nn.Dropout(dropout)
    
    def forward(self, x, mask=None):
        # Pre-norm: norm -> attention -> residual
        x_norm = self.attention_norm(x)
        attn_out, _ = self.attention(x_norm, x_norm, x_norm, attn_mask=mask)
        x = x + self.dropout(attn_out)
        
        # Pre-norm: norm -> FFN -> residual
        x_norm = self.ffn_norm(x)
        ff_out = self.ffn(x_norm)
        x = x + self.dropout(ff_out)
        
        return x

Practical Recommendations

  • Basic block: 2 conv layers, good for smaller networks
  • Bottleneck block: 1x1 -> 3x3 -> 1x1, parameter-efficient for deep networks
  • Stride 1 blocks: Maintain spatial dimensions
  • Stride 2 blocks: Downsample, increase channels
  • Pre-activation: Use for networks deeper than 100 layers
  • Number of blocks: [3, 4, 6, 3] is a good starting point (ResNet-34)

Residual connections are one of the most important architectural innovations in deep learning, enabling the training of networks that would otherwise be impossible to optimize.