To understand LSTM architecture, code a forward pass with just NumPy | by Greg Condit | Towards Data Science

If you’re like me, you learn best by starting simple and building from the ground up. No matter how often I read colah’s famous LSTM post, or Karpathy’s post on RNNs (great resources!), the LSTM network architecture seemed overly complicated and the gates were hazy.
Eventually, I wrote an LSTM forward pass with just NumPy. I highly recommend doing this exercise, because you can examine the hidden and cell states, inputs and outputs, and clearly see how they were created.
In this walkthrough, we will:
Represent each of the LSTM gates with its own distinct Python functionChain them together logically to create a NumPy Network with a single LSTM cell
Represent each of the LSTM gates with its own distinct Python function
Chain them together logically to create a NumPy Network with a single LSTM cell
Here’s the point: these functions can be edited to include print statements, which allows examination of the shapes of your data, the hidden state (short term memory), and the cell state (long term memory) at various stages throughout the LSTM cell. This will help you understand why the gates exist.
Secondly, to confirm we’ve done it right, we will:
Set up a small Neural Network with a single LSTM cell using PyTorchInitialize both networks with the same, random weightsMake 1 forward pass with both networks, and check that the output is the same
Set up a small Neural Network with a single LSTM cell using PyTorch
Initialize both networks with the same, random weights
Make 1 forward pass with both networks, and check that the output is the same
Let’s go!
Below is the LSTM Reference Card. It contains the Python functions, as well as an important diagram. On this diagram can be found every individual operation and variable (inputs, weights, states) from the LSTM gate functions. They are color-coded to match the gate they belong to.
The code for the functions can be copied below the card. I highly recommend saving this reference card, and using this to analyze and understand LSTM architecture. (A printable pdf version is available for download here.)
Here’s the copy-able code from the above reference card — one function for each gate:
Typically, an LSTM feeds a final, fully-connected linear layer. Let’s do that as well:
Create a PyTorch LSTM with the same parameters. PyTorch will automatically assign the weights with random values — we’ll extract those and use them to initialize our NumPy network as well.
OrderedDict([
  ('lstm.weight_ih_l0', tensor([[ 0.3813, -0.4317],
                                [ 0.4705,  0.3694],
                                [ 0.4851, -0.4427],
                                [-0.3875,  0.2747],
                                [-0.5389,  0.5706],
                                [ 0.1229,  0.0746],
                                [-0.4937,  0.1840],
                                [ 0.2483,  0.0916],
                                [ 0.5553,  0.1734],
                                [-0.5120,  0.4851],
                                [ 0.1960, -0.2754],
                                [-0.5303,  0.3291]])),
  ('lstm.weight_hh_l0', tensor([[ 0.5487, -0.4730,  0.0316],
                                [ 0.2071, -0.2726, -0.1263],
                                [-0.3855, -0.2730, -0.5264],
                                [-0.0134,  0.3423,  0.2808],
                                [ 0.5424, -0.5071, -0.0710],
                                [ 0.5621,  0.0945, -0.1628],
                                [-0.5200,  0.2687,  0.4383],
                                [ 0.4630,  0.4833,  0.1130],
                                [ 0.4115, -0.1453,  0.4689],
                                [-0.0494, -0.1191, -0.2870],
                                [ 0.3074,  0.2336,  0.3672],
                                [-0.3690, -0.3070,  0.5464]])),
  ('lstm.bias_ih_l0', tensor([-0.3205, -0.3293, -0.1545, -0.1866,
                              -0.3926,  0.4666,  0.0644,  0.2632,
                               0.4282, -0.3741,  0.4407, -0.2892])),
  ('lstm.bias_hh_l0', tensor([-0.0919,  0.4369,  0.5323,  0.5068,
                               0.3320,  0.5366, -0.2080, -0.0367,
                              -0.1975, -0.0424, -0.0702,  0.3085])),
  ('fc.weight', tensor([[ 0.3968, -0.4158, -0.3188]])),
  ('fc.bias', tensor([-0.1776]))
])

Don’t get overwhelmed! The PyTorch documentation explains all we need to break this down:
The weights for each gate in are in this order: ignore, forget, learn, output
keys with ‘ih’ in the name are the weights/biases for the input, or Wx_ and Bx_
keys with ‘hh’ in the name are the weights/biases for the hidden state, or Wh_ and Bh_
Given the parameters we chose, we can therefore extract the weights for the NumPy LSTM to use in this way:
Now, we have two networks — one in PyTorch, one in NumPy — with access to the same starting weights. We’ll put some time series data through each to ensure they are identical. To do a forward pass with our network, we’ll pass the data into the LSTM gates in sequence, and print the output after each event:
Good News! Putting the same data through the PyTorch model shows that we return identical output:
We can additionally verify that after the data has gone through the LSTM cells, the two models have the same hidden and cell states:
I hope this helps build an intuition for how LSTM networks make predictions! You can copy the whole code at once from this gist.
The LSTM Reference Card can be downloaded from this page, including printable versions. Thanks to Mike Ricos for collaborating on the creation of this great resource.