RNN language models and BPTT
Unroll an RNN as a computational graph, derive backpropagation through time (BPTT), and quantify the vanishing-gradient problem.
N-gram models fix context length to tokens, discarding everything before that window. A Recurrent Neural Network (RNN) maintains a hidden state that summarizes the entire prefix -- in principle, arbitrarily long dependencies are accessible. The neural language modeling program that RNN-LMs extend begins with Bengio et al.'s feedforward neural probabilistic LM[1].
The RNN recurrence
At each step , the RNN updates its hidden state and produces an output:
where is the input (word embedding), is the hidden state, and are unnormalized logits over the vocabulary.
Parameters: , , , and biases.
RNN as a language model
Apply softmax to to get . The training objective is cross-entropy on the next token at each position:
The same parameters are shared across all time steps -- the RNN "reuses" itself at every position.
Unrolling the computational graph
Training requires computing gradients of with respect to all parameters. Unrolling the RNN reveals a chain of copies of the same computation:
Backpropagation through time (BPTT)
Apply standard backprop to the unrolled graph. Define as the gradient of the loss with respect to the hidden state at time .
The gradient flows backward through the recurrence:
where is the Jacobian of . The gradient accumulates from the end of the sequence back to time by repeatedly multiplying by .
The vanishing-gradient problem
The gradient of the loss at step with respect to (for ) involves a product of Jacobians:
The spectral radius of and the saturating derivative interact:
- If the spectral radius : the product exponentially. Gradients vanish, and parameters at early time steps receive near-zero updates. Long-range dependencies cannot be learned.
- If : the product exponentially. Gradients explode. Fixed with gradient clipping: .
Put numbers on it. The derivative is at most , so the gradient norm is bounded by , and the spectral radius alone sets the rate. Span a 50-step dependency (, ) and watch where the factor lands:
- (slightly expansive): . Gradients explode by two orders of magnitude; clipping is needed to keep training stable.
- (the boundary): . The gradient is preserved, the only setting that carries signal cleanly across 50 steps, and it is measure-zero in practice.
- (barely contractive): . Survivable: the early token still feels about of the gradient.
- : . The early token receives half a percent of the signal; a 0.1-sized weight update at step 51 reaches step 1 as a nudge.
The asymmetry is the point. Exploding gradients are a nuisance with a one-line fix (clipping), but a vanishing gradient of is not "explode and rescale": there is almost no signal left to rescale, and the saturation only makes it smaller, since pushes the realistic factor well below . The vanishing case is the harder structural problem: clipping does not help a gradient of zero. LSTM and GRU (next lesson) solve this with gated, additive updates[2].
Implementation
import numpy as np
class VanillaRNN:
def __init__(self, V, d, m, seed=0):
rng = np.random.default_rng(seed)
scale = 0.1
self.Wx = rng.normal(0, scale, (m, V))
self.Wh = rng.normal(0, scale, (m, m))
self.Wo = rng.normal(0, scale, (V, m))
self.bh = np.zeros(m)
self.bo = np.zeros(V)
def forward(self, xs):
"""xs: list of one-hot vectors (T, V). Returns logits (T, V) and caches."""
hs, os, caches = [np.zeros(self.Wh.shape[0])], [], []
for x in xs:
z = self.Wh @ hs[-1] + self.Wx @ x + self.bh
h = np.tanh(z)
o = self.Wo @ h + self.bo
hs.append(h)
os.append(o)
caches.append((x, hs[-2], h, z))
return np.array(os), caches
def softmax(self, o):
e = np.exp(o - o.max())
return e / e.sum()
def next_token_loss(model, sentence_ids, V):
xs = np.eye(V)[sentence_ids[:-1]]
ys = sentence_ids[1:]
logits, _ = model.forward(xs)
loss = 0.0
for t, (logit, y) in enumerate(zip(logits, ys)):
prob = model.softmax(logit)
loss -= np.log(prob[y] + 1e-9)
return loss / len(ys)
# Tiny demo
V, d, m = 8, 4, 6
model = VanillaRNN(V, d, m)
ids = [0, 1, 2, 3, 2, 1, 0] # toy token sequence
print(f"Loss: {next_token_loss(model, ids, V):.3f}")Exercises
Exercise 1. A language model assigns probabilities , , to the three actual next tokens of a sequence. Compute the average cross-entropy loss (in nats) and the per-token perplexity by hand.
Solution
Step 1. The cross-entropy on each token is . The three negative log-probabilities (natural log) are , , .
Step 2. Average over the three tokens:
Step 3. Exponentiate to get perplexity: . Perplexity reads as the effective branching factor: this model is as uncertain as a uniform choice among about equally likely tokens at each step.
Exercise 2. Using the bound from the vanishing-gradient analysis, compute the factor for a -step dependency () at and at , and say which case clipping addresses.
Solution
Step 1. Contractive case : . The early token receives about of the gradient signal; this is the vanishing case, and clipping does nothing for it because there is almost no signal to rescale.
Step 2. Expansive case : . The gradient grows by a factor of about over steps; this is the exploding case, which gradient clipping caps directly.
Step 3. Clipping addresses only the (exploding) case. The vanishing case is the harder structural problem, the one LSTM and GRU solve with additive gated updates.
Exercise 3 (code). The vanishing/exploding behavior comes from the repeated factor . For a diagonal with every diagonal entry equal to , verify that the gradient-norm ratio after steps equals exactly, and that it is monotone increasing in .
Solution
import numpy as np
m, n = 4, 50
ratios = []
for rho in [0.9, 1.0, 1.1]:
W = np.diag(np.full(m, rho)) # spectral radius = rho
g = np.ones(m) # incoming gradient at step T
prop = np.linalg.matrix_power(W.T, n) @ g
ratios.append(np.linalg.norm(prop) / np.linalg.norm(g))
assert np.isclose(ratios[0], 0.9 ** 50) # 0.00515: vanishes
assert np.isclose(ratios[1], 1.0) # boundary: preserved
assert ratios[0] < ratios[1] < ratios[2] # monotone in rho
print("ratios:", [round(r, 4) for r in ratios])The ratio is when contractive, exactly at the boundary, and when expansive, matching the closed-form factor and confirming the exponential dependence on the spectral radius.
Vanilla RNNs are historical: they motivate the gated architectures that actually work. The next lesson derives LSTM's cell state, which maintains a direct gradient path over arbitrary lengths.
References
- [1]Bengio, Y., Ducharme, R., Vincent, P., & Jauvin, C. (2003). "A neural probabilistic language model". JMLR, 3, 1137-1155. The feedforward neural LM that distributed word representations grew from.↩
- [2]Hochreiter, S., & Schmidhuber, J. (1997). "Long short-term memory". Neural Computation, 9(8), 1735-1780. doi:10.1162/neco.1997.9.8.1735. Analyzes the vanishing gradient and introduces the gated fix.↩