Part of Language AI Handbook
Explains how BPTT unrolls RNNs for gradient computation, derives the chain rule through time, and implements truncated BPTT with detach() in PyTorch.
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
Backpropagation Through Time
Training a feedforward network is conceptually straightforward: run a forward pass through a fixed-depth computation graph, then run backpropagation to compute gradients with respect to every parameter. The graph has a clear start and end, and each layer gets visited exactly once in each direction. Every parameter is used at most once per forward pass, so its gradient is computed from a single path through the network.
Recurrent neural networks break that assumption. As we explored in the RNN Architecture chapter, an RNN processes a sequence one step at a time, feeding its hidden state forward from step to step. The same weight matrix gets applied at every timestep, which means a single parameter participates in every step of the computation. At step 1, it shapes the first hidden state. At step 2, it shapes the second. At step 50, it shapes the fiftieth. When you ask: "How should I adjust to reduce the loss?", the answer requires aggregating gradient contributions from all fifty steps simultaneously.
This is the problem that backpropagation through time (BPTT) solves. It is not a fundamentally new algorithm. BPTT is the same chain rule that powers all neural network training, applied to a computation graph that extends across time. The key insight is that by "unrolling" the RNN, you convert the looping recurrent structure into a deep but regular feedforward graph, and then you apply backpropagation to that unrolled graph exactly as you would to any other network.
The result is an elegant algorithm that correctly computes gradients for all shared parameters across all timesteps. But the unrolling operation exposes a fundamental tension: the deeper the unrolled graph (the longer the sequence), the longer the gradient paths, and the more severe the numerical problems that arise from multiplying many matrices together. BPTT's structure creates challenges that feedforward backprop never faces, chief among them the vanishing and exploding gradient problems we will examine closely in this chapter and the next.
This chapter explains how BPTT works from first principles, derives the gradient equations that flow backward through time, covers truncated BPTT for practical training on long sequences, analyzes the computational and memory costs in detail, and shows how to implement BPTT correctly in PyTorch. By the end, you will understand how the algorithm works, why it behaves the way it does on long sequences, and what practitioners have learned to do about it.
Unrolling the RNN
To understand BPTT, you first need a clear picture of what "unrolling" means, why it is conceptually useful, and what it costs in terms of computation and memory.
A standard RNN update at timestep is:
where:
- : the hidden state at timestep , a -dimensional vector that summarizes sequence history up to that point
- : the hidden state at the previous timestep, carrying information forward
- : the recurrent weight matrix, applied to the previous hidden state at every step
- : the input weight matrix, mapping inputs to the hidden space
- : the input vector at timestep
- : the bias vector
- : the hyperbolic tangent activation applied elementwise, squashing values to
This equation describes a computation that is deeply self-referential: to compute , you need . To compute , you need . The chain extends all the way back to , the initial hidden state (typically a zero vector). This is why RNNs are powerful: they can, in principle, let information from the very beginning of a sequence influence predictions at the very end.
From Loop to Graph
When you express an RNN as a recurrence relation, you are describing a loop: the same operation executed repeatedly, each time consuming the hidden state from the previous iteration. This compact representation is convenient for describing the model, but it is not the right representation for computing gradients.
Gradient computation requires a directed acyclic graph (DAG). The chain rule can only traverse a graph that does not have cycles, because cycles would create circular dependencies in the gradient equations. An RNN's loop is inherently cyclic: depends on which depends on , and so on. The way to resolve this is to expand the loop along the time dimension and treat each step as a distinct node in the graph.
Unrolling makes this expansion explicit. Instead of showing the RNN as a loop that re-uses a single node, you draw out a separate copy of the computation for each timestep. For a sequence of length , you get a graph with hidden state nodes connected in a chain: . Each node receives the previous hidden state and the current input and produces the next hidden state. The weight matrices and are shared across all copies, meaning they are the same physical parameters, but the intermediate values they produce at each step are all distinct and stored separately.
This is an important subtlety. Unrolling does not create new parameters; it creates new nodes in the computation graph that reference the same parameters. The implication for gradient computation is that each weight participates in computation paths, and its total gradient must account for all of them.
Outputs and the Loss Function
An output is computed from at each timestep (or at the final timestep only, depending on the task):
where:
- : the output weight matrix, mapping hidden state to output space (e.g., a vocabulary in language modeling)
- : the output bias
- : the output dimensionality (e.g., vocabulary size)
In many applications, an output is produced at every timestep. Language modeling is the canonical example: at each position in a sentence, the model predicts the next word. In sequence classification, outputs are produced only at the final step. In sequence-to-sequence models, outputs appear only during the decoding phase. BPTT applies in all of these cases, though the gradient structure differs slightly depending on where losses originate.
The total loss over a sequence is the sum of per-timestep losses:
where:
- : total sequence loss
- : loss at timestep , measuring prediction error at that step
- : sequence length
The additivity of the loss is important: it means the gradient of the total loss is the sum of gradients from each individual timestep. Each only directly depends on , which depends on . But is a function of all earlier hidden states and therefore of all earlier inputs and all applications of . So even though does not directly reference steps before , its gradient with respect to reaches all the way back through the entire history.
Unrolling converts the implicit loop of an RNN into an explicit feedforward computation graph. Each timestep becomes a distinct layer in this expanded graph. the same weight matrices appear at every layer, which means their gradients must be summed across all positions.
Deriving BPTT Gradients
With the unrolled computation graph established, standard backpropagation applies. The challenge is that the loss at time depends on , which depends on , which depends on , and so on. Computing the gradient of the total loss with respect to a parameter like requires following these chains back through time and aggregating every contribution.
The Chain Rule Through Time
The gradient of the total loss with respect to decomposes into per-timestep contributions:
where:
- : total gradient for the recurrent weight matrix
- : the gradient contribution from the loss at timestep
Each per-timestep gradient must account for the fact that depends on , which itself depends on through all previous timesteps. Applying the chain rule:
where:
- : gradient of the loss at time with respect to the hidden state at time , obtained directly from the output layer
- : Jacobian capturing how the hidden state at time depends on the hidden state at an earlier time , computed as a product of local Jacobians
- : how the hidden state at time directly depends on the weight matrix (via the term in the RNN update)
The last term, , captures the direct effect: at timestep , the operation uses explicitly, so differentiating this term with respect to gives (as an outer product structure). The middle term carries this local gradient signal all the way from timestep forward to timestep , where the loss occurs.
The Product of Jacobians
The middle term is the key quantity in understanding both how BPTT works and why it is numerically challenging. It is computed via the chain rule through the sequence of hidden state transitions from to :
where:
- : a product of Jacobian matrices, one for each timestep between and
- : the Jacobian of hidden state with respect to hidden state
Each local Jacobian captures two effects: the tanh nonlinearity applied elementwise at step , and the recurrent weight matrix that mixes hidden state dimensions. Working through the differentiation:
where:
- : a diagonal matrix whose entries are the tanh derivative at each hidden unit at timestep . The tanh derivative is , so the diagonal entries are for each dimension .
- : the transpose of the recurrent weight matrix, arising because we differentiate with respect to
The tanh derivative is a number between 0 and 1 for any input . When is large in magnitude (the tanh is saturated), the derivative is near 0. When is near zero, the derivative is near 1. This saturation behavior is the root cause of the vanishing gradient problem: at timesteps where hidden units are saturated, the local Jacobian multiplies the incoming gradient by something close to zero, annihilating it.
The product of these Jacobians across timesteps is what makes BPTT expensive and unstable. When is large (long sequences), you are multiplying many matrices together. If the eigenvalues of the resulting product are less than 1 in magnitude, the gradient vanishes exponentially. If greater than 1, it explodes exponentially. This is the vanishing and exploding gradient problem, analyzed in depth in the next chapter.
Gradient Accumulation Over Time
Because the same weight matrix is used at every timestep, its gradient is the sum of contributions from every timestep in the sequence. The complete gradient combines contributions from all future losses and all past hidden state paths:
where:
- The outer sum iterates over each timestep's loss
- The inner sum iterates over all past timesteps that contributed to through
- The product is the chain of Jacobians connecting time to time
This double sum has terms in total, though in practice the backward pass is structured to avoid redundant work through the delta recurrence described below. The same accumulation applies symmetrically to , with replacing the terminal term.
To see what this equation computes, think about a specific loss, say at timestep 10 of a 20-step sequence. This loss propagates a gradient signal backward through steps 10, 9, 8, ..., 1. This contributes to 's gradient via ten different paths of different lengths. Meanwhile, propagates through all 20 steps, and propagates through just 1 step. The total gradient for is the aggregate of all these signals, each attenuated by the product of Jacobians along its path.
The Error Signal Flowing Backward
In practice, BPTT works by defining a per-timestep error signal that captures the total gradient flowing into hidden state from all future losses. This quantity is evaluated right to left, from down to :
where:
- : the total error signal at timestep , combining local and future gradient contributions
- : the local gradient from the loss at time only (obtained from the output layer's backward pass)
- : the gradient flowing back from the next timestep, multiplied by the transposed Jacobian
- by convention (no loss beyond the last timestep)
The base case initializes , the gradient from the final timestep's local loss. Then the recurrence propagates this signal backward, accumulating contributions at each step. At each intermediate timestep , the error signal has two components: one from the local loss at that step, and one from all the future error signals that have been folded together through the backward recurrence. The second component is what allows gradients from the end of the sequence to reach parameters that contributed to early-sequence hidden states.
Once all values have been computed, the weight gradients follow directly from outer products summed across all timesteps:
where:
- : the elementwise product of the error signal with the tanh derivative vector , which applies the local activation gradient before accumulating
- : the previous hidden state as a row vector, so the outer product gives a matrix for
- : the input vector at time as a row vector, giving a matrix for
This is the same outer-product structure as a feedforward network, just summed over all timesteps rather than evaluated once. Each outer product contributes a rank-1 update to the gradient matrix, and the total gradient is the sum of such rank-1 updates. This structure also reveals why increasing hidden size increases the parameter count and gradient computation quadratically: the recurrent weight matrix is , and computing outer products at each of the timesteps scales as .
Why Gradients Must Flow Backward Through Time
It is worth stepping back to appreciate why gradient flow through time is necessary rather than a mathematical artifact. Consider training an RNN to model language. To predict the word "cat" at position 20 in a sentence that begins "The fluffy orange ___", the model needs to recognize that the adjectives "fluffy" and "orange" are relevant. Gradient signal flowing backward from the prediction error at position 20 to the words at positions 2, 3, and 4 is what teaches the model to pay attention to those early adjectives. If that gradient signal cannot reach those positions (because it has been attenuated to near-zero), the model cannot adjust its weights to learn those long-range associations. BPTT is the mechanism by which learning happens; gradient decay is the mechanism by which it fails.
Truncated BPTT
Full BPTT requires storing all hidden states and all activations for the entire sequence before running the backward pass. For a sequence of length , this means memory for activations and computation for the backward pass. When is thousands or tens of thousands of tokens, full BPTT becomes impractical.
Truncated BPTT addresses this by dividing the sequence into fixed-length chunks and running a separate forward/backward pass through each chunk. Within each chunk, gradients flow freely. Across chunks, the hidden state is passed forward but gradients are stopped at the chunk boundary.
How Truncated BPTT Works
The sequence of length is divided into non-overlapping chunks of length (the truncation length). For chunk spanning timesteps :
- The forward pass starts with the final hidden state from the previous chunk, , carried forward without gradient tracking
- The forward pass runs through all steps in the chunk, accumulating hidden states and losses
- The backward pass propagates gradients back through the steps in the chunk only
- The weight gradients are accumulated across all chunks
The stopping of gradients at chunk boundaries is implemented using PyTorch's detach() operation, which creates a new tensor with the same values but no gradient history.
The forward-pass hidden state still carries information from earlier in the sequence. When chunk begins, the initial hidden state encodes everything the network saw in chunks 0 through . That information is used in the forward pass to make predictions and compute losses. But the backward pass cannot trace gradients through into earlier chunks, because detach() has severed the computational graph. The result is an asymmetry: the model can use long-range information to make predictions, but cannot learn from gradient signal that would require looking beyond the current chunk boundary.
Truncated BPTT makes a practical trade-off: it gives up the ability to learn dependencies that span more than timesteps in exchange for bounded memory and predictable computation per update step. In practice, for most RNN training tasks, a truncation length between 20 and 200 captures the dependencies that matter. The exact value is a hyperparameter that depends on the sequence structure, and it interacts with the model's capacity to represent longer-range information in its hidden state.
Truncated BPTT limits gradient propagation to the most recent timesteps. The hidden state carries information from earlier in the sequence via forward propagation, but the gradient signal only travels back steps. This means the model can use long-range information for predictions but cannot update weights based on gradients from beyond steps ago.
The following visualization shows how gradient coverage changes with the truncation window relative to the full sequence length. Each bar represents the fraction of the sequence covered by one backward pass:

With , the 200-step sequence requires 10 separate backward passes. With , only 2 passes are needed. The trade-off is that larger chunks require more memory to store activations during the forward pass of each chunk.
Selecting the Truncation Length
The truncation length controls the trade-off between several competing factors:
- Memory cost: full BPTT requires memory; truncated BPTT requires memory per chunk
- Computational cost: each backward pass covers steps; you run backward passes per sequence
- Gradient quality: longer chunks capture more long-range dependencies; shorter chunks lose information about distant correlations
A common choice is for language modeling, matching the LSTM training paper by Zaremba et al. (2014). For tasks with explicit long-range dependencies, larger values or gated architectures such as LSTMs help.
Choosing the truncation length is not purely a memory decision; it should reflect the structure of the data. If the sequence contains strong dependencies that span 100 tokens, a truncation window of 20 will prevent the model from ever learning them through gradient updates. In practice, you can estimate the relevant dependency range by examining what information would be needed to make accurate predictions. For next-word prediction in typical English text, dependencies rarely need to span more than a few dozen words, which explains why modest truncation windows work well for language modeling.
One underappreciated consideration is the interaction between the truncation length and the learning dynamics. Very short truncation windows produce gradients that are effectively local to a small context window. The model learns to make predictions using only nearby context, which is fast but limits what it can ultimately learn. Very long truncation windows give the model more gradient signal but also give it vanishing gradient problems of its own, since the gradient still has to propagate through matrix multiplications to reach the beginning of the chunk.
Memory Requirements
The memory comparison between full BPTT and truncated BPTT is significant. For a sequence of length with hidden size and batch size :
- Full BPTT activations: bytes (all hidden states stored)
- Truncated BPTT activations: bytes (only current chunk's states stored)
For long sequences (e.g., , ), truncated BPTT reduces memory by roughly . The computational cost per step is similar, but the memory savings allow much larger batch sizes or hidden state sizes.
Beyond hidden states, the backward pass also requires storing intermediate activations, including the pre-activation values (the inputs to the tanh function) at each step, since the tanh derivative must be computed from these values. The total activation memory scales with the depth of the backward pass, making truncation doubly beneficial: it reduces both the hidden state storage and the intermediate activation storage.
Computational Cost of BPTT
Understanding the computational cost of BPTT matters because it directly constrains what models you can train on what hardware. The cost includes more than FLOPs; memory bandwidth, cache utilization, and the need to store intermediate activations all play important roles.
Let's be concrete. Suppose you have a sequence of length , hidden state dimension , input dimension , and batch size .
Forward pass cost (full sequence):
The dominant operation at each step is the matrix-vector product , which costs operations (since is ). Across all steps and batches, the forward pass costs .
Backward pass cost (full BPTT):
The backward pass has roughly the same cost as the forward pass, . Computing gradient contributions to involves outer products at each step costing , and contributions to cost per step.
Total cost (full BPTT): for the complete forward and backward passes.
Total cost (truncated BPTT with chunk size ): The same asymptotic cost, but practical benefits arise from fitting the computation within GPU cache, allowing larger batches, and parallelism across chunks. Each of the chunks runs a self-contained forward/backward pass of depth .
The hidden dimension dominates the cost in practice. Doubling the hidden size quadruples the computation for the recurrent matrix products. This is why RNNs with large hidden states are expensive to train on long sequences, and why the community moved toward transformer architectures that compute attention in rather than , a trade-off that favors larger at the cost of longer sequences.
The sequential nature of the RNN forward pass is a critical bottleneck that the computational cost analysis understates. To compute , you must first have computed . This strict temporal dependency means that no matter how many parallel processors you have, the forward pass through a sequence of length requires sequential steps. Modern GPUs are optimized for massively parallel operations, and sequential dependencies waste this parallelism. A transformer processes all tokens in parallel (though attention still has quadratic cost in sequence length), which is one key reason transformers train dramatically faster than RNNs on modern hardware despite having similar theoretical FLOPs.
Gradient Checkpointing
For applications that require full BPTT on long sequences, gradient checkpointing offers a practical solution. Instead of storing all intermediate activations during the forward pass, you store only a subset of checkpoint activations and recompute the missing ones during the backward pass when they are needed.
The trade-off is straightforward: checkpointing reduces memory by a factor of roughly (with the right checkpointing strategy) at the cost of one additional forward pass through the checkpointed segments. If the sequence is very long and memory is the bottleneck, this trade is usually worth it. PyTorch provides checkpointing support through torch.utils.checkpoint.checkpoint_sequential, which can be applied to RNN segments without modifying the gradient computation.
Gradient checkpointing is especially useful during fine-tuning of pretrained language models on long documents, where full sequences may contain thousands of tokens that would exhaust GPU memory under standard full-BPTT training. The additional compute overhead of recomputation is typically a 30-50% increase in training time, which is often acceptable when the alternative is running out of memory or reducing batch size to a single example.
A Worked Example
Derivations and code can make BPTT seem abstract. Working through a tiny numerical example by hand is the fastest way to build intuition about what the equations compute, where the values come from, and why the backward pass has to visit timesteps in reverse order.
Let's trace through BPTT on a minimal example to make the mechanics concrete.
Consider an RNN with hidden dimension , processing a sequence of three steps. The recurrent weight matrix is:
After the forward pass, the hidden states are:
The error signals flowing back from the output layer (local gradients from each timestep's loss) are:
These local gradients come from the output layer: specifically, they are the derivatives of each timestep's loss with respect to the hidden state at that step. In practice, this derivative is computed by backpropagating through the output weight matrix and then through the softmax (if applicable). For this example, we take them as given to focus on the backward-through-time portion.
Step 1: Initialize at the last timestep
The backward pass starts from the final timestep. The initial error signal is simply the local gradient:
The tanh derivative (elementwise) at timestep 3 is:
The modified error (tanh derivative applied before backpropagating) is:
Step 2: Propagate backward to
The recurrence carries the error signal one step further back:
Computing the matrix-vector product:
Note that is with rows and columns swapped, because we are differentiating with respect to .
Adding the local gradient:
Step 3: Accumulate the gradient contribution to
At each timestep, the contribution to is an outer product of the modified error signal and the previous hidden state. For timestep 3:
where:
- : the modified error vector at time 3
- : the previous hidden state as a row vector
- The outer product gives a gradient matrix matching the shape of
The total gradient for is the sum of these outer products across all three timesteps:
This example illustrates several important properties of BPTT. First, the backward pass must run in strict reverse order: you cannot compute until you have computed . Second, each timestep's gradient contribution is an outer product that has exactly the shape of the weight matrix, which is why the gradient accumulation is just a simple sum. Third, the magnitude of the gradient contribution from each timestep depends both on the local error signal and on how much that signal has been attenuated by the chain of Jacobians from later steps.
Understanding What the Gradients Tell Us
It is instructive to think about what the gradient says. The entry at position in this matrix tells you: if you increase the weight connecting hidden dimension of the previous state to hidden dimension of the current state, how does the total loss change? A large positive value means increasing that weight increases the loss. A large negative value means increasing it decreases the loss. A near-zero value means that weight has minimal effect on the loss, perhaps because the gradient signal has been attenuated by vanishing gradients along its path.
When the gradient for is very small (close to the zero matrix), gradient descent will barely update the recurrent weights. This is the vanishing gradient problem: the model cannot effectively learn from gradient signal that traveled a long path through the unrolled network. When the gradient is very large, a single gradient step will make a drastic change to the weights, destabilizing training. The next chapter examines both pathologies in rigorous detail.
Implementing BPTT in PyTorch
PyTorch's autograd system handles full BPTT automatically when you call .backward() on a loss computed over a sequence. The computational graph is built during the forward pass and traversed during the backward pass. All you need to do is ensure you are not breaking the graph with unnecessary detach() calls.
Setting Up the RNN
Let's build a simple character-level language model to demonstrate BPTT:
import numpy as np
import torch
import torch.nn as nn
# Set random seed for reproducibility
torch.manual_seed(42)
np.random.seed(42)
# Simple RNN for character-level language modeling
class SimpleRNN(nn.Module):
def __init__(self, input_size, hidden_size, output_size):
super().__init__()
self.hidden_size = hidden_size
self.rnn = nn.RNN(input_size, hidden_size, batch_first=True)
self.fc = nn.Linear(hidden_size, output_size)
def forward(self, x, hidden=None):
# x: (batch, seq_len, input_size)
output, hidden = self.rnn(x, hidden)
# output: (batch, seq_len, hidden_size)
logits = self.fc(output)
# logits: (batch, seq_len, output_size)
return logits, hiddenModel parameters: 7,707 RNN parameters: 5,952 FC parameters: 1,755
The model has a standard RNN layer followed by a linear output layer. The number of parameters depends only on the vocabulary size and hidden dimension, not the sequence length, because the weights are shared across all timesteps. This weight sharing is what gives RNNs their efficiency in parameters, but it also creates the gradient challenges we have been examining.
The nn.RNN module in PyTorch implements the vanilla RNN we have been deriving. Internally, it runs the recurrence at each timestep and accumulates the hidden states. When .backward() is called on the final loss, PyTorch automatically unrolls this computation and computes BPTT gradients through the accumulated graph.
Full BPTT with PyTorch Autograd
Full BPTT is the default behavior in PyTorch. When you run a forward pass through the entire sequence and then call .backward(), PyTorch automatically propagates gradients through all timesteps:
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss()
# Create synthetic sequence data: random one-hot inputs
def make_batch(batch_size, seq_len, vocab_size):
# Random input token indices
input_idx = torch.randint(0, vocab_size, (batch_size, seq_len))
# One-hot encode
x = torch.zeros(batch_size, seq_len, vocab_size)
x.scatter_(2, input_idx.unsqueeze(2), 1.0)
# Target: next-character prediction (shift by 1)
target_idx = torch.randint(0, vocab_size, (batch_size, seq_len))
return x, target_idx
x, targets = make_batch(batch_size, seq_length, vocab_size)
# Full BPTT: forward through entire sequence, then backward
optimizer.zero_grad()
hidden = None # Start with zero hidden state
logits, hidden = model(x, hidden)
# logits: (batch, seq_len, vocab_size)
# Reshape for cross-entropy: (batch * seq_len, vocab_size)
loss = criterion(logits.reshape(-1, vocab_size), targets.reshape(-1))
loss.backward() # Full BPTT: gradients flow through all seq_length steps
optimizer.step()Loss: 3.2914 Gradient norm (W_hh): 0.051048 Gradient norm (W_ih): 0.051249 Sequence length processed: 20
The gradient norms show how much signal reaches the weight matrices from backpropagating through all seq_length timesteps. For short sequences, these gradients are healthy. For very long sequences (hundreds of steps), they often collapse toward zero, illustrating the vanishing gradient problem.
Implementing Truncated BPTT with detach()
The key to truncated BPTT is the detach() operation. When you detach the hidden state before passing it to the next chunk, you pass the values forward (preserving memory across chunks) without passing the gradient history (stopping gradient flow at chunk boundaries):
def train_truncated_bptt(
model, optimizer, criterion, seq_length, chunk_size, vocab_size, batch_size
):
"""
Demonstrates truncated BPTT.
Processes a long sequence in chunks of chunk_size.
Hidden state is carried across chunks; gradients are truncated.
"""
# Create a long sequence
total_steps = seq_length
x_full, targets_full = make_batch(batch_size, total_steps, vocab_size)
total_loss = 0.0
num_chunks = 0
# Start with zero hidden state
hidden = None
for start in range(0, total_steps, chunk_size):
end = min(start + chunk_size, total_steps)
# Extract chunk
x_chunk = x_full[:, start:end, :]
t_chunk = targets_full[:, start:end]
# CRITICAL: detach hidden state before each chunk
# This passes values forward but stops gradient flow backward
if hidden is not None:
hidden = hidden.detach()
optimizer.zero_grad()
# Forward pass through this chunk only
logits, hidden = model(x_chunk, hidden)
chunk_loss = criterion(
logits.reshape(-1, vocab_size), t_chunk.reshape(-1)
)
chunk_loss.backward() # Backward only through this chunk
optimizer.step()
total_loss += chunk_loss.item()
num_chunks += 1
return total_loss / num_chunks, num_chunks
avg_loss, n_chunks = train_truncated_bptt(
model,
optimizer,
criterion,
seq_length=200,
chunk_size=35,
vocab_size=vocab_size,
batch_size=batch_size,
)Average chunk loss: 3.2992 Number of chunks processed: 6 Chunk size: 35 steps Total sequence: 200 steps Gradient truncation: every 35 steps
The detach() call is the single most important line in truncated BPTT. Without it, gradients would flow back through the entire sequence history, turning the training into full BPTT but with multiple optimizer steps mid-sequence. With it, each chunk is a self-contained backward pass.
One common mistake when implementing truncated BPTT is calling optimizer.zero_grad() before detaching the hidden state. The order matters: you should detach the hidden state from the previous chunk before zeroing gradients, not after. The code above handles this correctly, but it is easy to get wrong when refactoring.
Monitoring Gradient Flow
A useful diagnostic is to track gradient norms over training. Healthy gradients have norms that do not consistently collapse to zero or explode to large values:
def train_with_gradient_monitoring(model, seq_lengths, n_steps=100):
"""
Train model on sequences of different lengths and monitor gradient norms.
Returns gradient norm history for each sequence length.
"""
grad_histories = {}
for seq_len in seq_lengths:
model_copy = SimpleRNN(vocab_size, hidden_size, vocab_size)
opt = torch.optim.Adam(model_copy.parameters(), lr=1e-3)
grad_norms = []
for step in range(n_steps):
x, targets = make_batch(4, seq_len, vocab_size)
opt.zero_grad()
logits, _ = model_copy(x)
loss = criterion(
logits.reshape(-1, vocab_size), targets.reshape(-1)
)
loss.backward()
# Record gradient norm for recurrent weights
grad_norm = model_copy.rnn.weight_hh_l0.grad.norm().item()
grad_norms.append(grad_norm)
opt.step()
grad_histories[seq_len] = grad_norms
return grad_histories
seq_lengths_to_compare = [5, 15, 30, 60]
grad_histories = train_with_gradient_monitoring(
model, seq_lengths_to_compare, n_steps=80
)
The gradient norms reveal a clear pattern: longer sequences produce smaller gradients for the recurrent weight matrix. This is the vanishing gradient problem in action. The gradient signal must travel through more matrix multiplications to reach the earliest timesteps, and each multiplication by (combined with the tanh derivative) tends to shrink the signal. This degradation motivates the architectural solutions covered in the following chapters on LSTMs and GRUs.
Full vs. Truncated BPTT: Side-by-Side Comparison
Let's compare the gradient behavior of full BPTT and truncated BPTT on the same sequence:
def compare_bptt_methods(
seq_length, chunk_size, vocab_size, batch_size, n_trials=5
):
"""
Compare gradient norms from full BPTT vs. truncated BPTT.
"""
full_norms = []
trunc_norms = []
for _ in range(n_trials):
x, targets = make_batch(batch_size, seq_length, vocab_size)
# Full BPTT
model_full = SimpleRNN(vocab_size, hidden_size, vocab_size)
opt_full = torch.optim.Adam(model_full.parameters(), lr=1e-3)
opt_full.zero_grad()
logits, _ = model_full(x)
loss = criterion(logits.reshape(-1, vocab_size), targets.reshape(-1))
loss.backward()
full_norms.append(model_full.rnn.weight_hh_l0.grad.norm().item())
# Truncated BPTT (using only the last chunk for comparison)
model_trunc = SimpleRNN(vocab_size, hidden_size, vocab_size)
opt_trunc = torch.optim.Adam(model_trunc.parameters(), lr=1e-3)
opt_trunc.zero_grad()
# Use only the last chunk_size steps for backward
x_chunk = x[:, -chunk_size:, :]
t_chunk = targets[:, -chunk_size:]
logits_chunk, _ = model_trunc(x_chunk)
loss_chunk = criterion(
logits_chunk.reshape(-1, vocab_size), t_chunk.reshape(-1)
)
loss_chunk.backward()
trunc_norms.append(model_trunc.rnn.weight_hh_l0.grad.norm().item())
return (
np.mean(full_norms),
np.std(full_norms),
np.mean(trunc_norms),
np.std(trunc_norms),
)
seq_len = 60
chunk = 20
full_mean, full_std, trunc_mean, trunc_std = compare_bptt_methods(
seq_len, chunk, vocab_size, batch_size
)Sequence length: 60, Chunk size: 20 Full BPTT gradient norm: 0.040708 +/- 0.012397 Truncated BPTT gradient norm: 0.062081 +/- 0.011493 Ratio (trunc / full): 1.53x
Truncated BPTT tends to produce larger gradient norms than full BPTT on long sequences, precisely because it avoids the extended chain of matrix products that attenuates the signal in full BPTT. This counter-intuitive result is why truncated BPTT can sometimes train faster: the gradients are noisier (they do not account for long-range dependencies), but they are not vanishing.
The trade-off here is between gradient fidelity and gradient magnitude. Full BPTT computes the exact gradient of the loss with respect to , but on long sequences this exact gradient may be so small that learning is effectively stalled. Truncated BPTT computes an approximate gradient that ignores long-range contributions, but the resulting gradient is often large enough to update the weights. For tasks where long-range dependencies matter, this approximation can harm final model quality; for tasks dominated by local context, it often works as well or better.
Key Parameters
The key parameters for BPTT training are:
- chunk_size (truncation window): Controls how many timesteps are unrolled for each backward pass. Typical values are 20-200. Larger values capture longer dependencies but use more memory.
- hidden_size: The dimensionality of the hidden state. Larger hidden states give the model more capacity but increase both forward and backward pass costs as .
- gradient clipping threshold: As covered in the Gradient Clipping chapter, clipping by global norm (typical threshold 1.0-5.0) prevents exploding gradients from destabilizing training.
Memory Tradeoffs: Full vs. Truncated BPTT
Let's visualize the memory tradeoff concretely:
def compute_memory_requirements(
seq_lengths, chunk_sizes, hidden_dim, batch_size, bytes_per_float=4
):
"""
Estimate memory requirements for full and truncated BPTT.
Memory proportional to number of stored activations.
"""
results = {}
for seq_len in seq_lengths:
# Full BPTT: store all hidden states for entire sequence
full_mem_mb = (seq_len * hidden_dim * batch_size * bytes_per_float) / (
1024**2
)
results[seq_len] = {"full": full_mem_mb}
for chunk_size in chunk_sizes:
# Truncated BPTT: store only current chunk's hidden states
trunc_mem_mb = (
chunk_size * hidden_dim * batch_size * bytes_per_float
) / (1024**2)
results[seq_len][f"trunc_{chunk_size}"] = trunc_mem_mb
return results
seq_lengths_mem = [50, 100, 250, 500, 1000]
chunk_sizes_mem = [25, 50, 100]
hidden_dim_large = 512
batch_size_large = 32
mem_data = compute_memory_requirements(
seq_lengths_mem, chunk_sizes_mem, hidden_dim_large, batch_size_large
)
The chart makes the advantage of truncated BPTT clear. For a sequence of length 1000 with hidden dimension 512 and batch size 32, full BPTT requires roughly 64 MB of activations just for the hidden states. Truncated BPTT with chunk size 50 reduces this to about 3.2 MB per chunk, a reduction. This is before accounting for the even larger memory needed to store the pre-activation values (inputs to the tanh function), which are also needed for computing the tanh derivatives during the backward pass.
Gradient Flow Through Hidden States
The quality of gradient signal reaching early timesteps determines whether the model can learn long-range dependencies. We can track how gradient norms decay as we move further back in time using PyTorch's retain_grad():
def gradient_decay_by_position(
seq_len, vocab_size=27, hidden_size=64, batch_size=4
):
"""
Compute gradient norms as a function of distance from the end of the sequence.
Uses manual RNN forward pass to retain gradients at each hidden state.
"""
torch.manual_seed(0)
m = SimpleRNN(vocab_size, hidden_size, vocab_size)
x, targets = make_batch(batch_size, seq_len, vocab_size)
# Initial hidden state: requires_grad=True so retain_grad() works
h_prev = torch.zeros(batch_size, hidden_size, requires_grad=True)
all_h = []
# Manual forward to retain all hidden state grads
for t in range(seq_len):
x_t = x[:, t, :] # (batch, input_size)
h_new = torch.tanh(
x_t @ m.rnn.weight_ih_l0.T
+ m.rnn.bias_ih_l0
+ h_prev @ m.rnn.weight_hh_l0.T
+ m.rnn.bias_hh_l0
) # (batch, hidden_size) - requires_grad=True because it depends on parameters
h_new.retain_grad()
all_h.append(h_new)
h_prev = h_new
# Apply the loss only at the final timestep. This isolates the gradient
# propagated backward through the recurrent chain instead of adding a
# direct loss contribution at every hidden state.
logits = m.fc(all_h[-1])
loss = criterion(logits, targets[:, -1])
loss.backward()
grad_norms = []
for h_t in all_h:
if h_t.grad is not None:
grad_norms.append(h_t.grad.norm().item())
else:
grad_norms.append(0.0)
return grad_norms
seq_len_decay = 30
grad_decay = gradient_decay_by_position(seq_len_decay)
The gradient decay plot reveals why vanilla RNNs struggle with long-range dependencies. Early timesteps receive only a tiny fraction of the gradient signal that the later timesteps receive. This asymmetry means the model updates its weights based primarily on recent history, making it difficult to learn patterns that require looking far back in time.
This plot also reveals something subtle about the nature of learning in RNNs: even if a model could in principle encode long-range dependencies in its hidden state (and a sufficiently large hidden state can represent very complex functions), training with BPTT cannot teach it to do so if the gradients decay before reaching the relevant early timesteps. The representational capacity of the model and the trainability of that capacity are two different things. A vanilla RNN might theoretically be able to remember that a sentence began with "The fluffy orange", but BPTT cannot reliably teach it to do so if those words appeared many steps ago.
The LSTM and GRU architectures, introduced in the following chapters, address this limitation through gating mechanisms that provide more direct gradient paths through time. By using learned gates to selectively write to and read from a cell state, LSTMs can maintain gradients that are closer to 1.0 in magnitude over long distances, allowing gradient signal to travel dozens or hundreds of steps without severe attenuation.
Historical Context and BPTT's Origins
Backpropagation through time was not invented all at once. The algorithm's history reflects the broader history of learning in recurrent networks and reveals why certain design choices were made.
The conceptual framework for training recurrent networks via gradient descent was laid out by David Rumelhart, Geoffrey Hinton, and Ronald Williams in their 1986 paper on backpropagation in feedforward networks. The extension to recurrent networks was recognized soon after: if you unroll a recurrent network across time, it becomes a deep feedforward network, and standard backpropagation applies. Paul Werbos described this approach formally in the late 1980s, and it was further developed and popularized by Williams and Zipser in 1989 and 1995. The term "backpropagation through time" became standard in the early 1990s.
The vanishing gradient problem was identified as the key obstacle by Sepp Hochreiter in his 1991 German-language diploma thesis, a remarkable piece of work that performed a systematic analysis of why standard RNNs failed to learn long-range dependencies. Hochreiter showed mathematically that gradient magnitudes decrease exponentially with distance from the output, making it essentially impossible to train vanilla RNNs on tasks requiring memory beyond about 10-20 timesteps. This analysis directly motivated the design of the Long Short-Term Memory (LSTM) network, published by Hochreiter and Schmidhuber in 1997.
The LSTM's central innovation was the constant error carousel: a cell state that gradients can flow through without passing through the tanh nonlinearity, provided the forget gate remains open. This design creates paths where the product of Jacobians can be kept near 1.0, solving the vanishing gradient problem not by changing the training algorithm but by changing the network architecture.
It is worth appreciating what this history reveals about the relationship between algorithms and architectures in deep learning. BPTT is the correct algorithm for training any differentiable recurrent system. The problem is not the algorithm; the problem is that vanilla RNNs have gradient flow properties that make the algorithm fail to learn long-range dependencies. The solution was not to fix BPTT but to design architectures whose gradient flow properties are compatible with BPTT. This same pattern appears throughout deep learning: batch normalization was designed partly to improve gradient flow in very deep feedforward networks, and residual connections in ResNets create shortcut paths that keep gradients healthy at depth.
Comparing BPTT Across RNN Variants
Once you understand BPTT for a vanilla RNN, it is informative to consider how the same algorithm behaves for LSTMs and GRUs. These architectures do not require a different training algorithm; they use BPTT exactly as described above. What they change is the structure of the forward computation, which in turn changes the structure of the Jacobians that appear in the backward pass.
BPTT in an LSTM
An LSTM maintains two state vectors at each timestep: the hidden state (sometimes called the output state) and the cell state (the memory). The key gradient path in an LSTM runs through the cell state, not the hidden state. The cell state update equation is:
where is the forget gate and is the input gate. The gradient of with respect to is simply : a diagonal matrix containing the forget gate values. Because forget gate values are squashed by a sigmoid into , this Jacobian is always diagonal and always has entries between 0 and 1. The forget gate is learned and can be set close to 1.0 for dimensions that need long-term memory. When the forget gate is near 1, gradients flow backward through the cell state with almost no attenuation.
This is a fundamentally different gradient regime from the vanilla RNN, where the recurrent Jacobian involves both the tanh derivative (always in ) and a full matrix that can have large or small spectral properties depending on training. The LSTM essentially replaces that matrix product with a learned diagonal gate, giving the model explicit control over gradient flow.
BPTT in a GRU
A Gated Recurrent Unit (GRU) achieves similar benefits with a simpler structure, maintaining only one state vector. The update rule involves a reset gate and an update gate :
The gradient of with respect to has a component proportional to . When the update gate is close to 0, this component is close to 1, passing gradients backward with minimal attenuation. The GRU achieves a similar constant-error-carousel effect to the LSTM through the interpolation structure of the update gate.
Both LSTMs and GRUs still use BPTT. The benefit they provide is architectural: by creating gradient paths that do not inevitably pass through full matrix products and saturated nonlinearities, they allow BPTT to compute meaningful gradients even at large distances from the output. The training algorithm is unchanged; the model is designed to be more amenable to training.
Practical Pitfalls and Implementation Advice
Training RNNs with BPTT involves several common pitfalls that can cause silently incorrect or very slow training.
Forgetting to Detach Between Epochs
When iterating over a dataset multiple times, a common mistake is to carry the hidden state from the end of one epoch into the beginning of the next without detaching it. If the hidden state from epoch 's last batch is passed directly into epoch 's first batch without calling .detach(), the computational graph grows across batches. By the time you call .backward() on the epoch loss, PyTorch will try to backpropagate through the hidden state all the way back to wherever the graph started in epoch . This can lead to memory errors (the graph is unexpectedly large) or incorrect gradients (the graph spans multiple batches of unrelated data).
The safe practice is to always initialize the hidden state fresh at the start of each independent sequence in your dataset, or to call .detach() on any hidden state that is being carried across batch boundaries.
Not Resetting Hidden State Between Sequences
When processing a dataset of multiple independent sequences (documents, sentences, examples), you must reset the hidden state between sequences. Each sequence in the dataset is independent; the hidden state from processing document A should not be used as the initial state for processing document B. Failing to reset leads to a subtle bug where the model's predictions at the start of each sequence are influenced by the content of the previous sequence.
This bug is particularly insidious because the training loss may still decrease: the model might learn to use the leaked context from the previous sequence for certain tasks. But the model's behavior at inference time will be wrong if sequences are processed in a different order or in isolation, because the expected hidden state contamination will not be present.
Exploding Gradients and Gradient Clipping
While vanishing gradients are the chronic problem, exploding gradients are the acute one. A single exploding gradient event can corrupt a model's weights catastrophically, requiring restarting training from a checkpoint. Gradient clipping, covered in detail in the Gradient Clipping chapter, is the standard remedy: before applying the optimizer step, you rescale the global gradient norm to a maximum threshold value if it exceeds that threshold.
In PyTorch, gradient clipping is one line:
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)This should be called after loss.backward() and before optimizer.step(). The typical threshold for RNN training is 1.0 to 5.0, with 1.0 being a conservative and widely-used default.
The Learning Rate's Interaction with Gradient Scale
The learning rate for RNN training typically needs to be smaller than for feedforward networks, because the gradient scale varies significantly with sequence length. When you increase the sequence length (or chunk size), the gradient norms change, and a learning rate that worked well for short sequences may be too large or too small for longer ones. Using adaptive optimizers like Adam helps, because Adam normalizes gradients per-parameter, but even with Adam, careful learning rate tuning is often necessary when changing the sequence length.
A related consideration is learning rate warmup: starting with a very small learning rate and gradually increasing it to the target value over the first few thousand steps. Warmup helps because early in training, the model's hidden states are poorly initialized and the gradient surface is unstable. Large early gradient steps can send weights to poor regions of parameter space from which recovery is slow.
Limitations and Practical Considerations
BPTT is the correct training algorithm for RNNs, but it comes with practical constraints that shaped how RNN-based systems were designed for many years.
The most significant limitation is the cost of backpropagating through long sequences. Every additional timestep adds a matrix multiplication to the gradient path, and each multiplication tends to shrink the gradient (if the spectral radius of is less than 1) or grow it (if greater than 1). This sensitivity to the eigenvalue spectrum of the recurrent weight matrix is mathematically fundamental. BPTT faithfully computes the true gradient; the problem is that the true gradient is often very small or very large for long sequences.
Truncated BPTT is the standard practical solution, but it introduces its own issues. The truncation window acts as a hard limit on the dependencies the gradient can capture. A model trained with chunk size 35 simply cannot learn from gradient signal originating more than 35 steps in the past, no matter how informative that signal would be. This is not a memory issue; it is a deliberate algorithmic choice that trades gradient completeness for computational tractability. For some tasks, such as parsing long documents or understanding multi-paragraph narratives, this limitation is severe.
Memory also presents a real barrier for full BPTT on modern hardware. During the backward pass, PyTorch needs to retain all intermediate activations from the forward pass to compute gradients. For a sequence of length 1000 with a hidden state of dimension 512 and batch size 32, this amounts to hundreds of megabytes for a single sequence, before accounting for optimizer state. Gradient checkpointing offers a middle ground: instead of storing all activations, you recompute them during the backward pass, trading compute for memory. PyTorch supports this via torch.utils.checkpoint.checkpoint_sequential.
The sequential nature of RNN computation is arguably the deeper limitation. Because depends on , the forward pass cannot be parallelized across timesteps. For a sequence of length 1000, you must execute 1000 sequential matrix operations, regardless of available hardware parallelism. Modern GPUs can process the matrix operations for a batch of sequences in parallel, but they cannot process different timesteps of the same sequence in parallel. This makes RNNs fundamentally slow to train compared to transformers, which can process all positions in a sequence simultaneously through attention.
Despite these limitations, BPTT remains the conceptual foundation for training all sequence models. The gated architectures (LSTMs, GRUs) covered in the next chapters do not replace BPTT; they improve the gradient flow properties of the network so that BPTT works better. Even modern transformer architectures, which sidestep recurrence entirely through attention, are trained using the same chain-rule-based backpropagation, just through a different computational graph. Understanding BPTT deeply is the foundation for understanding why LSTMs work, why transformers are preferred for long sequences, and what any future sequence model needs to address.
One subtle practical issue deserves mention: the handling of the initial hidden state. Typical practice initializes . In truncated BPTT, the hidden state at the start of each chunk is the detached final state from the previous chunk. This detachment means the model cannot update weights to make better use of the carried-forward state based on losses from future chunks. In practice this is acceptable, but it does mean the first few steps of each chunk may be slightly miscalibrated as the hidden state re-accumulates context.
An alternative approach sometimes used in practice is to learn the initial hidden state as a parameter. By treating as a trainable vector, the model can learn a good starting state that minimizes the warmup problem at the beginning of each sequence or chunk. This is a small but non-negligible improvement for tasks where the first few tokens of a sequence are important.
Summary
Backpropagation through time trains recurrent neural networks by unrolling the recurrent computation graph into a feedforward graph and applying standard backpropagation through it. The core ideas are:
- Unrolling converts recurrence to depth: An RNN over a sequence of length becomes a feedforward network with layers for the purpose of gradient computation, with all layers sharing the same weight matrices.
- Gradient accumulation: Because weights are shared across all timesteps, the gradient for any parameter is the sum of contributions from every timestep where that parameter participated.
- The backward error signal: The recurrence propagates gradient information from later timesteps to earlier ones, accumulating contributions at each step.
- Truncated BPTT: Dividing sequences into chunks and using
detach()at chunk boundaries bounds memory and computation while preserving the ability to carry forward state information. - Gradient decay: Vanilla RNNs suffer from vanishing gradients when trained with BPTT on long sequences because the product of Jacobians across many timesteps tends toward zero. This motivates the gated architectures in the following chapters.
- Sequential computation: The strict timestep-by-timestep dependency in RNN forward passes limits hardware utilization and makes RNNs slower to train than architectures that can parallelize across positions, which was a key motivation for the development of the transformer.
The next chapter examines the vanishing gradient problem in detail, analyzing exactly why the product of Jacobians causes gradient signal to decay and what that means for what vanilla RNNs can and cannot learn.
Quiz
Ready to test your understanding? Take this quick quiz to reinforce what you've learned about backpropagation through time.
Backpropagation Through Time Quiz
Reference
Citation details
Cite or share this article.
Continue with the full handbook
This chapter is part of Language AI Handbook. Use the handbook page to browse the complete table of contents and continue reading in sequence.
Explore Language AI HandbookStay up to date
Get articles, book updates, and news delivered to your inbox.
No spam, unsubscribe anytime.
Join the community
Sign in to remove popups, track your reading progress, and join the discussion.

Comments
No comments yet. Be the first to share your thoughts!