%matplotlib inline
from d2l import jax as d2l
from flax import nnx
import jax
from jax import numpy as jnp
import math
import random
import timeAn RNN language model on The Time Machine, over the 1,024-token BPE vocabulary, built twice: from raw tensor ops, then with the framework’s recurrent layer. Four pieces:
Parameters: \mathbf{W}_{xh}, \mathbf{W}_{hh}, \mathbf{b}. Initialize randomly, scaled to keep activations sensible:
class RNNScratch(nnx.Module):
"""The RNN model implemented from scratch."""
def __init__(self, num_inputs, num_hiddens, sigma=0.01, rngs=None):
rngs = nnx.Rngs(0) if rngs is None else rngs
self.num_inputs, self.num_hiddens = num_inputs, num_hiddens
self.sigma = sigma
self.W_xh = nnx.Param(
rngs.params.normal((num_inputs, num_hiddens)) * sigma)
self.W_hh = nnx.Param(
rngs.params.normal((num_hiddens, num_hiddens)) * sigma)
self.b_h = nnx.Param(jnp.zeros(num_hiddens))Walk a length-T input one step at a time, carrying the hidden state forward:
@d2l.add_to_class(RNNScratch)
def __call__(self, inputs, state=None):
if state is None:
# Initial state with shape: (batch_size, num_hiddens)
state = jnp.zeros((inputs.shape[1], self.num_hiddens))
outputs = []
for X in inputs: # Shape of inputs: (num_steps, batch_size, num_inputs)
state = d2l.tanh(d2l.matmul(X, self.W_xh) +
d2l.matmul(state, self.W_hh) + self.b_h)
outputs.append(state)
return outputs, stateSanity check on output shapes:
def check_len(a, n):
"""Check the length of a list."""
assert len(a) == n, f'list\'s length {len(a)} != expected length {n}'
def check_shape(a, shape):
"""Check the shape of a tensor."""
assert a.shape == shape, \
f'tensor\'s shape {a.shape} != expected shape {shape}'
check_len(outputs, num_steps)
check_shape(outputs[0], (batch_size, num_hiddens))
check_shape(state, (batch_size, num_hiddens))With |\mathcal{V}| = 1{,}024, one-hot inputs waste a 1,024-wide multiply per step on a vector of zeros.
Embedding in, vocab-sized projection out; plot perplexity instead of loss:
class RNNLMScratch(d2l.Classifier):
"""The RNN-based language model implemented from scratch."""
def __init__(self, rnn, vocab_size, lr=0.01, rngs=None):
super().__init__()
self.save_hyperparameters(ignore=['rnn', 'rngs'])
self.rnn = rnn
rngs = nnx.Rngs(1) if rngs is None else rngs
self.W_e = nnx.Param(
rngs.params.normal((vocab_size, rnn.num_inputs)))
self.W_hq = nnx.Param(rngs.params.normal(
(rnn.num_hiddens, vocab_size)) * rnn.sigma)
self.b_q = nnx.Param(jnp.zeros(vocab_size))
def training_step(self, batch):
return self.loss(self(*batch[:-1]), batch[-1])
def validation_step(self, batch):
return self.loss(self(*batch[:-1]), batch[-1])
def plot(self, key, value, train):
# The train/val steps run inside `@nnx.jit` and only return the mean
# loss: plotting a tracer from there would crash the board's drawing
# thread. `Trainer.fit_epoch` instead calls this with the materialized
# loss (outside jit), which we relabel as perplexity for parity with
# the other tabs.
if key == 'loss':
key, value = 'ppl', d2l.exp(value)
super().plot(key, value, train)Project every hidden state through the shared head, then a shape smoke test: (batch, steps) ids in, (batch, steps, vocab) logits out:
@d2l.add_to_class(RNNLMScratch)
def output_layer(self, rnn_outputs):
outputs = [d2l.matmul(H, self.W_hq) + self.b_q for H in rnn_outputs]
return d2l.stack(outputs, 1)
@d2l.add_to_class(RNNLMScratch)
def forward(self, X, state=None):
embs = self.embedding(X)
rnn_outputs, _ = self.rnn(embs, state)
return self.output_layer(rnn_outputs)Backprop through T steps multiplies T Jacobians, one explosion-prone product. Clip the gradient onto a ball of radius \theta before each update:
\mathbf{g} \leftarrow \min\!\left(1, \frac{\theta}{\|\mathbf{g}\|}\right)\mathbf{g}.
@d2l.add_to_class(d2l.Trainer)
def clip_gradients(self, grad_clip_val, grads):
grad_leaves, _ = jax.tree_util.tree_flatten(grads)
norm = jnp.sqrt(sum(jnp.vdot(x, x) for x in grad_leaves))
clip = lambda grad: jnp.where(norm < grad_clip_val,
grad, grad * (grad_clip_val / norm))
return jax.tree_util.tree_map(clip, grads)(PyTorch/MXNet: called by fit_epoch; TF: inside the compiled step; JAX: optax.clip_by_global_norm does it inside fit.)
50k windows of 32 BPE tokens, batch 1024, 10 epochs, clip at 1. Fresh zero state per window = truncated BPTT:
validation perplexity 88.3
Val ppl ~90–100 over 1,024 tokens vs. char-level ppl ~7 over 27: not comparable. Convert to bits per byte:
2.78 bytes/token, 2.33 bits per byte
~2.4 bpb beats the char-trigram baseline’s 2.68 bpb: the “worse” perplexity is the better language model.
Warm up on the prefix, then feed each chosen token back in. Greedy (T=0) or temperature sampling:
@d2l.add_to_class(RNNLMScratch)
def predict(self, prefix, num_tokens, tok, device=None, temperature=0.0,
rng=None):
model = nnx.view(self, deterministic=True, use_running_average=True,
raise_if_not_found=False)
outputs, state = tok.encode(prefix), None
for i in range(len(outputs) - 1): # Warm up on the prefix
X = d2l.tensor([[outputs[i]]])
_, state = model.rnn(model.embedding(X), state)
rng = random.Random() if rng is None else rng
for _ in range(num_tokens): # Generate num_tokens continuation tokens
X = d2l.tensor([[outputs[-1]]])
rnn_outputs, state = model.rnn(model.embedding(X), state)
logits = d2l.numpy(model.output_layer(rnn_outputs))[0, 0]
if temperature == 0:
outputs.append(int(logits.argmax()))
else:
weights = [math.exp(l) for l in
(logits - logits.max()) / temperature]
outputs.append(rng.choices(range(len(weights)), weights)[0])
return tok.decode(outputs)"the time traveller.\n\n'In the Time Traveller, and\nsossibly the Time Traveller.\n\n'In the Time Traveller, and\nsossibly the Time Traveller.\n\n'In the ...
Greedy is fluent, then circles. Sampling breaks the loop at the price of stranger choices:
the time traveller soly in a little recognive out of pastentre Neverildren of explain genepecies than
the time traveller.
'Soverny, but the half-dimension of the probleiss, and I had come
Doing better = decoding strategies, later in this chapter.
Same interface as RNNScratch, one fused call:
class RNN(nnx.Module):
"""The RNN model implemented with high-level APIs."""
def __init__(self, num_inputs, num_hiddens, rngs=None):
rngs = nnx.Rngs(0) if rngs is None else rngs
self.num_inputs, self.num_hiddens = num_inputs, num_hiddens
self.rnn = nnx.RNN(
nnx.SimpleCell(num_inputs, num_hiddens, rngs=rngs),
time_major=True, return_carry=True, rngs=rngs)
def __call__(self, inputs, H=None):
H, outputs = self.rnn(inputs, initial_carry=H)
return outputs, HThe LM wrapper is inherited: swap in framework embedding and dense layers:
class RNNLM(d2l.RNNLMScratch):
"""The RNN-based language model implemented with high-level APIs."""
def __init__(self, rnn, vocab_size, lr=0.01, rngs=None):
d2l.Classifier.__init__(self)
self.save_hyperparameters(ignore=['rnn', 'rngs'])
self.rnn = rnn
rngs = nnx.Rngs(2) if rngs is None else rngs
self.emb = nnx.Embed(vocab_size, rnn.num_inputs,
embedding_init=nnx.initializers.normal(1.0),
rngs=rngs)
self.linear = nnx.Linear(rnn.num_hiddens, vocab_size, rngs=rngs)
def embedding(self, X):
return self.emb(X.T)
def output_layer(self, hiddens):
return d2l.swapaxes(self.linear(hiddens), 0, 1)Untrained model generates byte soup, but the wiring (tokenizer to model and back) is sound:
'it has m\x1d e5asause sornessare ex( lab� Thenhing�imes now strange'
Same trainer, same data:
total_loss = num_tokens = 0
for X_val, y_val in data.val_dataloader():
losses = model.loss(model(X_val), y_val, averaged=False)
total_loss += float(losses.sum())
num_tokens += losses.size
ppl_concise = math.exp(total_loss / num_tokens)
pred = model.predict('the time traveller', 30, data.tokenizer)
print(f'perplexity {ppl_concise:.1f}, {pred!r}')perplexity 107.5, "the time traveller, and\nthemathem of\nthem, and,\nthe silent of of\nthe Time Traveller. 'You"
model time (s) val ppl
scratch 21.0 88.3
concise 15.0 107.5
SimpleRNN has no fused GPU kernel; the compiled scratch loop matches it.num_steps tokens.