I’ve spent the last week hand-rolling recurrent neural networks. I’m currently taking Udacity’s Deep Learning course, and arriving at the section on RNN’s and LSTM’s, I decided to build a few for myself.
What are RNN’s?
On the outside, recurrent neural networks differ from typical, feedforward neural networks in that they take a sequence of input instead of an input of fixed length. Concretely, imagine we are training a sentiment classifier on a bunch of tweets. To embed these tweets in vector space, we create a bag-of-words model with vocabulary size 3. In a typical neural network, this implies an input layer of size 3; an input could be , or , or , for example. In a recurrent neural network, our input layer has the same size 3, but instead of just a single size-3 input, we can feed it a sequence of size-3 inputs of any length. For example, an input could be , or , or .
On the inside, recurrent neural networks have a different feedforward mechanism than typical neural networks. In addition, each input in our sequence of inputs is processed individually and chronologically: the first input is fed forward, then the second, and so on. Finally, after all inputs have been fed forward, we compute some gradients and update our weights. Like in feedforward networks, we also use backpropagation. However, we must now backpropagate errors to our parameters at every step in time. In other words, we must compute gradients with respect to: the state of the world when we fed our first input forward, the state of the world when we fed our second input forward, and up until the state of the world when we fed our last input forward. This algorithm is called Backpropagation Through Time.
Other Resources, My Frustrations
There are many resources for understanding how to compute gradients using Backpropagation Through Time. In my view, Recurrent Neural Networks Maths is the most mathematically comprehensive, while Recurrent Neural Networks Tutorial Part 3 is more concise yet equally clear. Finally, there exists Andrej Karpathy’s Minimal character-level language model, accompanying his excellent blog post on the general theory and use of RNN’s, which I initially found convoluted and hard to understand.
In all posts, I think the authors unfortunately blur the line between the derivation of the gradients and their (efficient) implementation in code, or at the very least jump too quickly from one to another. They define variables like
delta_t, and without thoroughly explaining their place in the analytical gradients themselves. As one example, the first post includes the snippet:
So far, he’s just talking about analytical gradients. Next, he gives hint to the implementation-in-code that follows.
So the thing to note is that we can delay adding in the backward propagated errors until we get further into the loop. In other words, we can initially compute the derivatives of J with respect to the third unrolled network with only the first term:
And then add in the other term only when we get to the second unrolled network:
Note the opposing definitions of the variable . As far as I know, the latter is, in a vacuum, categorically false. This said, I believe the author is simply providing an alternative definition of this quantity in line with a computational shortcut he later takes.
Of course, these ambiguities become very emotional, very quickly. I myself was confused for two days. As such, the aim of this post is to derive recurrent neural network gradients from scratch, and emphatically clarify that all implementation “shortcuts” thereafter are nothing more than just that, with no real bearing on the analytical gradients themselves. In other words, if you can derive the gradients, you win. Write a unit test, code these gradients in the crudest way you can, watch your test pass, and then immediately realize that your code can be made more efficient. At this point, all “shortcuts” that the above authors (and myself, now, as well) take in their code will make perfect sense.
Backpropagation Through Time
In the simplest case, let’s assume our network has 3 layers, and just 3 parameters to optimize: , and . The foundational equations of this network are as follows:
I’ve written “softmax” and “cross-entropy” for clarity: before tackling the math below, it is important to understand what they do, and how to derive their gradients by hand.
Before moving forward, let’s restate the definition of a partial derivative itself.
A partial derivative, for example , measures how much increases with every 1-unit increase in .
Our cost is the total cost (i.e., not the average cost) of a given sequence of inputs. As such, a 1-unit increase in will impact each of , and individually. Therefore, our gradient is equal to the sum of the respective gradients at each time step :
Let’s take this piece by piece.
Starting with , we note that a change in will only impact at time : plays no role in computing the value of anything other than . Therefore:
Starting with , a change in will impact our cost in 3 separate ways: once, when computing the value of ; once, when computing the value of , which depends on ; once, when computing the value of , which depends on , which depends on .
More generally, a change in will impact our cost on separate occasions. Therefore:
Then, with this definition, we compute our individual gradients as:
Finally, we plug in the individual partial derivates to compute our final gradients, where:
- , where is a one-hot vector of the correct answer at a given time-step
- , as
At this point, you’re done: you’ve computed your gradients, and you understand Backpropagation Through Time. From this point forward, all that’s left is writing some for-loops.
As you’ll readily note, when computing the gradient for, for example, , we’ll need access to our labels at time-steps , and . For , we’ll need our labels at time-steps and . Finally, for , we’ll need our labels at just . Naturally, we look to make this efficient: for, for example, , how about just compute the parts at , and add in the rest at ? Instead of explaining further, I leave this step to you: it is ultimately trivial, a good exercise, and when you’re finished, you’ll find that your code readily resembles much of that written in the above resources.
Throughout this process, I learned a few lessons.
- When implementing neural networks from scratch, derive gradients by hand at the outset. This makes thing so much easier.
- Turn more readily to your pencil and paper before writing a single line of code. They are not scary and they absolutely have their place.
- The chain rule remains simple and clear. If a derivative seems to “supercede” the general difficulty of the chain rule, there’s probably something else you’re missing.
Key references for this article include:
- Recurrent Neural Networks Tutorial Part 2 Implementing A Rnn With Python Numpy And Theano
- Recurrent Neural Networks Tutorial Part 3 Backpropagation Through Time And Vanishing Gradients
- The Unreasonable Effectiveness of Recurrent Neural Networks
- Minimal character-level language model with a Vanilla Recurrent Neural Network, in Python/numpy
- Machine Learning – Recurrent Neural Networks Maths