Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Recurrent neural network

So far, our simple text generation model still has two bottlenecks on the road to real language understanding:

  • It cannot understand word order: “the mouse loves rice” and “rice loves the mouse” produce the same context feature. The important information of the order in which words appear is lost.

  • It lacks long-range memory: the model’s view is limited to a narrow context size. For text beyond that range, the model cannot build any association between the meanings. Take this lyric: “I love you like a mouse loves rice”. With a context size of 5, the model cannot learn the association between “I love you” and “a mouse loves rice”.


How to give a machine “long-term memory” is not a new problem.

In 1990, the linguist Elman proposed the Simple RNN. Elman fed the hidden state back into the next moment, which became the standard paradigm of the modern recurrent neural network (RNN): the model looks not only at the current input, but also at its own past.

The hidden state is the state of the input data inside the network after layer upon layer of transformation, just before it enters the final output layer. It holds a summary of everything the network has learned up to this moment, which is what we call temporal memory.

Looking back at our word vector model, it has three layers: the embedding layer, the hidden layer, and the output layer. The output of the hidden layer (after the activation function) is the hidden state.

Simply put, a recurrent neural network calls the process of reading one character a time step, and connects the hidden state of the previous time step, taken just before the output layer, to the word vector built by the embedding layer in the current time step. A time step then consists of four steps:

  • the embedding layer does the first round of learning on the input data and produces a word vector;

  • the word vector is connected with the hidden state of the previous time step;

  • the word vector, now carrying temporal memory, is learned from further, producing the hidden state of the current time step;

  • the output layer produces the prediction.

The final output has two parts, the prediction and the hidden state. The prediction is used by the loss function to start the gradient computation and backpropagation, and the hidden state takes part in the next time step.

Recurrent neural network: the input feature x and the hidden state h of the previous time step together produce the new hidden state h′, from which the prediction p′ is obtained
Figure: Recurrent neural network

Where:

  • xx: the (input) feature;

  • hh: the hidden state (of the previous time step);

  • h′h^{\prime}: the (output) hidden state;

  • p′p^{\prime}: the (output) prediction.

Tensor

Backward method

The backward method of a tensor is the main carrier of backpropagation: in it, we traverse all the parent nodes in the computational graph and compute their gradients.

So far we have used a very simple recursive traversal. It works when all the nodes of the graph form a line or a tree, where each node is visited only once. A recurrent neural network breaks exactly this assumption. In an RNN, the hidden state hh has to be sent to the output layer to produce a prediction and also passed to the next time step to be updated further, so the same node is reached by two paths and visited once on each; and the same computation is called dozens of times. As a result, the same node in the computational graph is visited several times, and its gradient is computed repeatedly.

The fix is to switch to a topological sort: first arrange all the nodes of the graph in dependency order, so that every node comes after all of its parent nodes. Then, starting from the end of this node sequence (the loss tensor), we visit each node in reverse order and compute its gradient. Each node is visited once and only once, so the gradient computation neither repeats nor misses anything.

Addition

An important step of an RNN is adding the hidden state hh of the previous time step to the new feature embedding vector. This is not a simple addition of two vectors: like the embedding layer and the mean pooling layer, it has to build the computational graph in forward propagation and compute the gradients in backpropagation.

Our solution is to extend the addition operation of the tensor: while adding, we also implement the gradient function gradient_fn and the parent list parents of the resulting tensor.

Dataset

Abstract class

Character-encoding dataset

In the previous chapters, we always used the one-hot encoding of the next character as the label of a sample.

An RNN, however, processes a sample one character at a time. For example, with a context size of 32, each sample is 32 characters. Starting from the first character, the RNN makes 32 predictions in turn, each time carrying the hidden state of the previous step. In total it predicts 32 characters, which correspond exactly to the 32 characters shifted by one position.

So in an RNN model we will use:

  • xx (feature): tokens[i: i + self.context_size];

  • yy (label): tokens[i + 1: i + self.context_size + 1].

That is, if the feature is the characters at positions 1 to 32 of the text, the label is the characters at positions 2 to 33, which correspond to the results of the 32 predictions of the RNN.

Model

Abstract layer

Linear layer

Composite layer

We abstract the composite layer out of the sequential layer, keeping only the function of a container for sublayers. The composite layer will be the base layer of the more complex network layers to come.

Sequential layer

The sequential layer now inherits from the composite layer, with the same function.

Embedding layer

Activation functions

Rectified linear unit (ReLU)

Hyperbolic tangent (Tanh)

Loss functions

Abstract class

Cross-entropy loss function

The dataset no longer returns one-hot encodings as labels, but sequences of index encodings in the same format as the feature.

We therefore upgrade the cross-entropy loss function. By checking the format of the label, it supports both kinds of datasets: one-hot encodings and sequences of index encodings.

Optimizer (stochastic gradient descent)

Recurrent neural network

Recurrent cell

The core of an RNN is the recurrent cell (Cell). It does only one thing: it fuses the character just read, xtx_t, and the earlier memory, ht−1h_{t-1}, into the new memory hth_t.

It has three sublayers inside:

  • input (input linear layer): processes the character just read, xtx_t;

  • hidden (hidden state linear layer): processes the earlier memory ht−1h_{t-1};

  • tanh (activation function): squeezes the sum of the two above into (−1,1)(-1, 1), giving the new memory hth_t.

As a formula:

ht=tanh⁡(Win xt+Whidden ht−1+b)h_t = \tanh(W_{in} \, x_t + W_{hidden} \, h_{t-1} + b)

Where:

  • ht−1h_{t-1}: the memory of the previous time step;

  • hth_t: the updated memory (of the current time step).

In the code, each of the two linear layers has its own bias, and bb in the formula is their sum.

The memory is passed on in this way, one character after another, which is where the name “recurrent” comes from.

RNN model

Applying the recurrent cell to a complete neural network gives us the RNN. It processes a complete feature (a sequence of characters) as follows:

  • embedding: each character is converted into an embedding vector by the embedding layer;

  • cell: the embedding vector is fused with the earlier memory (the hidden state of the previous step) to give the new memory (the hidden state of this step);

  • output: the new memory goes through the output layer to give the predicted probabilities of the next character.

The for loop in the inference method forward feeds the character sequence of the feature, one character at a time, into the same recurrent cell cell, and passes the new memory on to the next time step. In this way, every time step trains the same set of recurrent cell parameters.

💡 Tip: So the training of an RNN has three nested loops: epochs, iterations, and time steps. This is also why it is not efficient.

Backpropagation through time

This structure, which shares the same cell and unrolls along the time steps, also makes the gradient flow back along the time steps all the way to the very beginning during backpropagation. This is called backpropagation through time (BPTT).

The gradient computation has to pass through dozens or even hundreds of time steps, which easily causes vanishing or exploding gradients. For this reason, RNN training can also use a technique called truncated BPTT: instead of letting the gradient pass through the whole sequence, the sequence is divided into shorter time segments, and backpropagation is carried out only within a limited number of time steps. This reduces the computation and memory cost, but it also means the model cannot directly learn dependencies over longer distances through backpropagation.

Vanishing and exploding gradients are one of the main reasons why RNNs find it hard to learn long-term dependencies. Even truncated BPTT cannot solve this problem at its root. This is also one of the reasons why improved RNNs such as LSTM and GRU, and architectures such as the Transformer, were developed afterwards.

Trainer

Abstract class

RNN trainer

Training method train: training an RNN along the time steps is slightly different from the earlier multi-layer networks. The model returns a series of predictions at once (one per time step) instead of a single prediction. So we add up the losses of all the time steps of one inference and then do a single backpropagation.

Here we make use of the addition __add__ that we added to the tensor:

loss += self.loss_fn(...)

While accumulating the loss of each time step, this also chains the computational graphs of the time steps together. With a single backpropagation, the gradient follows the computational graph through every time step and flows back to every parameter.

Generation method generate: the generation method also processes one character at a time:

  1. First let the model process the prompt prompt token by token, updating the memory;

  2. Predict the next character from the current memory, and update the memory again;

  3. Repeat step 2, and chain the prediction of each step into the final output text.

Hyperparameters

Learning rate

Batch size

Number of epochs

Context size

Embedding size

Modeling

We have implemented all the sublayers of the RNN in the recurrent cell Cell and the network model RNN, so we only need to create an instance of RNN.

Training

Evaluation

prediction:	32 steps, each (6970, 65)
total loss:	Tensor(68.8332931423169)

Inference

ROMEO:
Hat the will you at resters hex, peale Integ stair,
Shate whis. Bute to wain ray batter
Pon's that wigr,
Shist loble it a er? Andake stave ilis muthponl?

TUIEG:
Thald;
And her it fees sRish co'r, forg be cruls fort sad
zould fire I hork have me, me a Pro

This time, we have finally let the network model start understanding word order: through the memory (hidden state) mechanism, the RNN processes the input one character at a time and updates its memory after every character.

The inference result above shows that the output now has the shadow of words. We can already recognize some words, which is a big improvement over the earlier pile of random characters. It also shows that the RNN really works.

Exercises

  1. Replace the activation function in Cell from Tanh with ReLU, train again, and observe how the generated text differs. Think about the reason in light of the output range of Tanh, (−1,1)(-1, 1).