4.Feed-forward method for LSTM block

As always, we will create the feed-forward algorithm in the FeedForward method. The feed-forward pass method is one of the basic methods that is defined by a virtual method in the base class and is overridden in all inherited methods.

class CNeuronLSTM    :  public CNeuronBase
  {
protected:
   ....   
public:
   ....   
   virtual bool      FeedForward(CNeuronBase *prevLayeroverride;

The FeedForward method receives a pointer to the previous neural layer as a parameter, which contains the initial data for the method operation. It returns a logical value indicating the execution status of the method operations.

At the beginning of the method, we check the validity of pointers to all objects that are critical for the method operations. If there is at least one invalid pointer, we exit the method with the result of false.

bool CNeuronLSTM::FeedForward(CNeuronBase *prevLayer)
  {
--- check the relevance of all objects
   if(!prevLayer || !prevLayer.GetOutputs() || !m_cOutputs ||
      !m_cForgetGate || !m_cInputGate || !m_cOutputGate ||
      !m_cNewContent)
      return false;

After successfully passing through the control block, we create stubs for new memory buffers and hidden states. To do this, we use the CreateBuffer method discussed above, remembering to control the result of the operations.

--- prepare blanks for new buffers
   if(!m_cForgetGate.SetOutputs(CreateBuffer(m_cForgetGateOuts), false))
      return false;
   if(!m_cInputGate.SetOutputs(CreateBuffer(m_cInputGateOuts), false))
      return false;
   if(!m_cOutputGate.SetOutputs(CreateBuffer(m_cOutputGateOuts), false))
      return false;
   if(!m_cNewContent.SetOutputs(CreateBuffer(m_cNewContentOuts), false))
      return false;
   CBufferType *memory = CreateBuffer(m_cMemorys);
   if(!memory)
      return false;
   CBufferType *hidden = CreateBuffer(m_cHiddenStates);
   if(!hidden)
     {
      delete memory;
      return false;
     }

Next, we have to prepare the initial data for the correct operation of the internal layers. This procedure is not as simple as it may seem at first glance. The reason is that to call the feed-forward methods of our gates, we require not just a buffer but a neural layer. We cannot put a pointer to the previous layer obtained in the parameters, because it does not contain all the necessary information. It lacks the hidden state data necessary for the algorithm to function correctly. Therefore, we will need to create an empty neural layer and fill its output buffer with the necessary data.

But before creating a new neural layer, we verify the validity of the pointer to the stack storing the source data neural layers. If needed, we create a new one, as after conducting the feed-forward pass, we will need to store the created neural layer for subsequent neural network training. The check for the stack presence is performed before completing the entire loop of feed-forward operations, in order to save resources by avoiding unnecessary operations.

--- create a buffer for the source data
   if(!m_cInputs)
     {
      m_cInputs = new CArrayObj();
      if(!m_cInputs)
        {
         delete memory;
         delete hidden;
         return false;
        }
     }

Please note that before exiting the method after an unsuccessful attempt to create a new stack, we will need to delete the objects created within the method, for which pointers are not passed to the global variables of the class.

Next, we create a new instance of the base neural layer object. And, as always, we check the result of the operation.

   CNeuronBase *inputs = new CNeuronBase();
   if(!inputs)
     {
      delete memory;
      delete hidden;
      return false;
     }

After successfully creating an instance of the base neural layer object, we need to create an object describing the structure of the neural layer for its initialization. That's what we'll proceed to do. We will create an instance of the CLayerDescription object and populate it with the necessary data. We will specify the type of neuron layer as defNeuronBase. The number of elements in the neural layer will be equal to the sum of the elements in the result buffers of the previous and current layers. Since we will directly populate the result buffer of the created layer from other sources, we set the window size for source data to 0.

   CLayerDescription *desc = new CLayerDescription();
   if(!desc)
     {
      delete inputs;
      delete memory;
      delete hidden;
      return false;
     }
   desc.type = defNeuronBase;
   desc.count = (int)(prevLayer.GetOutputs().Total() + m_cOutputs.Total());
   desc.window = 0;

After creating the description of the neural layer, we proceed to its initialization. Upon successful completion of the operation, we delete the no-longer-needed layer description object.

   if(!inputs.Init(desc))
     {
      delete inputs;
      delete memory;
      delete hidden;
      delete desc;
      return false;
     }
   delete desc;
   inputs.SetOpenCL(m_cOpenCL);

After this, we only need to fill the result buffer of the new layer with the necessary source data. To begin with, we get a pointer to the required buffer and verify its validity.

   CBufferType *inputs_buffer = inputs.GetOutputs();
   if(!inputs_buffer)
     {
      delete inputs;
      delete memory;
      delete hidden;
      return false;
     }

After that, we populate the buffer with the contents of the result buffers from the previous layer and the hidden state. We have moved the functionality of data transfer to a separate Concatenate method, which we will consider later.

   if(!inputs_buffer.Concatenate(prevLayer.GetOutputs(), hidden,
                                 prevLayer.Total(), hidden.Total()))
     {
      delete inputs;
      delete memory;
      delete hidden;
      return false;
     }

Now that we have completed the preparatory work, we can proceed directly to the feed-forward pass operations. We will start this process by calling the feed-forward pass methods of the internal neural layers. First, we will perform the forward pass for the forget gates. We simply call the method with the same name on the corresponding object. We will pass a pointer to the newly created instance of the neural layer for the source data as parameters to the method, and then we check the result of the operation execution.

--- perform a feed-forward pass of the internal neural layers
   if(!m_cForgetGate.FeedForward(inputs))
     {
      delete inputs;
      delete memory;
      delete hidden;
      return false;
     }

We will repeat the operation for all internal layers.

   if(!m_cInputGate.FeedForward(inputs))
     {
      delete inputs;
      delete memory;
      delete hidden;
      return false;
     }

   if(!m_cOutputGate.FeedForward(inputs))
     {
      delete inputs;
      delete memory;
      delete hidden;
      return false;
     }

   if(!m_cNewContent.FeedForward(inputs))
     {
      delete inputs;
      delete memory;
      delete hidden;
      return false;
     }

After successfully completing the feed-forward pass of all internal neural layers, the buffer of results for each object will store prepared information about the state of all gates and the normalized data of the new content. Now all we have to do is to combine all information flows according to the algorithm of the LSTM block. Before constructing this process, we need to organize the branching of the algorithm depending on the utilized device for computational operations: CPU using standard MQL5 tools or OpenCL context.

A reasonable question may arise: why are we separating transaction threads only now? Why didn't we utilize the power of multi-threaded operations when calculating gate state and new context? But believe me, these operations also utilized the technology of multi-threaded computations offered by OpenCL, although not as explicitly. For example, in the CNeuronLSTM::SetOpenCL method, we passed a pointer to the OpenCL context to all the internal neural layers, and just a few lines above, we called the feed-forward pass methods for each internal layer. And now take a look at the forward pass method of the parent class CNeuronBase::FeedForward, there is also thread division present there.

bool CNeuronBase::FeedForward(CNeuronBase *prevLayer)
  {
   ....   
--- Branching the algorithm by the computing device
   if(!m_cOpenCL)
     {
   ....   
     }
   else
     {
   ....   
     }
//---
   return false;
  }

In other words, we have previously used ready-made methods of the base class of neural layers with ready-made functionality in both directions. We will now introduce additional operations that are unique to the LSTM block. Therefore, we need to split the thread of operations and organize the process for both technologies. Just as when building the previous classes, we will now go through the process of constructing the algorithm in MQL5. We will delve into the actual process organization within the context of OpenCL in the next chapter.

When performing operations using MQL5, we will first obtain pointers to the data buffers with the results of internal neural layers in local variables for ease of access. Then we will use the matrix operations of MQL5.

First, we multiply element-wise the Memory state by the Forget Gate values. We then multiply the normalized matrix of new content (New Content) by the Input Gate, step by step. The result is added to the updated memory state (Memory). In conclusion, we normalize the results of the operations performed above using the hyperbolic tangent function and then element-wise multiply them with the matrix of gate results (Output Gate). The result is written to the hidden state buffer (Hidden).

--- branching of the algorithm by the computing device
   CBufferType *fg = m_cForgetGate.GetOutputs();
   CBufferType *ig = m_cInputGate.GetOutputs();
   CBufferType *og = m_cOutputGate.GetOutputs();
   CBufferType *nc = m_cNewContent.GetOutputs();
   if(!m_cOpenCL)
     {
      memory.m_mMatrix *= fg.m_mMatrix;
      memory.m_mMatrix += ig.m_mMatrix * nc.m_mMatrix;
      hidden.m_mMatrix = MathTanh(memory.m_mMatrix) * og.m_mMatrix;
     }

For the OpenCL context algorithm, we temporarily set an exit with a negative result, which will later be replaced by the correct code. This option will allow us to test the ready code and warn us about choosing an incorrect parameter.

   else
     {
      delete inputs;
      delete memory;
      delete hidden;
      return false;
     }

After completing the loop that updates the full memory and hidden state of our LSTM block, we transfer the hidden state values to the result buffer.

--- copy the hidden state to the neural layer results buffer
   m_cOutputs = hidden;

This could be the end of the feed-forward pass. However, we still need to save the current state for the subsequent training of our recurrent block. First, we save the initial data to the stack. As mentioned above, we insert new objects into the stack with an index of 0.

--- save the current state
   if(!m_cInputs.Insert(inputs0))
     {
      delete inputs;
      delete memory;
      delete hidden;
      return false;
     }

After adding a new element, check the stack for overflow and remove excessive historical data. To perform this functionality, we create the ClearBuffer method. We will look at the algorithm of this method a little later.

   ClearBuffer(m_cInputs);

Here it should be mentioned that we store the source data in the form of a neural layer. This allows us to solve two problems at once:

  1. The feed-forward and backpropagation methods for the base neural layer require a pointer to the previous neural layer as input. Consequently, a single object can be used for both the feed-forward and backpropagation passes without any modifications to the base neural layer.
  2. In one object, we store both the raw data buffer and the gradient buffer. We do not need to configure synchronization for buffer utilization.

In the remaining stacks, we will store buffers. Therefore, we will create an additional InsertBuffer method for the repetitive work of saving data to the stacks. We will take a look at the algorithm of the method a bit later, and for now, we will use it to copy information into the stacks. We will repeat the call of the specified method for each stack and the corresponding buffer.

   if(!InsertBuffer(m_cForgetGateOutsm_cForgetGate.GetOutputs(), false))
     {
      delete memory;
      delete hidden;
      return false;
     }

   if(!InsertBuffer(m_cInputGateOutsm_cInputGate.GetOutputs(), false))
     {
      delete memory;
      delete hidden;
      return false;
     }

   if(!InsertBuffer(m_cOutputGateOutsm_cOutputGate.GetOutputs(), false))
     {
      delete memory;
      delete hidden;
      return false;
     }

   if(!InsertBuffer(m_cNewContentOutsm_cNewContent.GetOutputs(), false))
     {
      delete memory;
      delete hidden;
      return false;
     }

Note that above, we saved the buffers of results from internal layers. These objects belong to the neural layer structure and will be deleted from memory together when the corresponding neural layer is deleted. Therefore, in the InsertBuffer method, we will not create a new instance of the buffer object and copy the data.

Here, it's crucial to have a clear understanding of the differences between a pointer to an object and the object itself. Every time we create an object, a certain amount of memory is allocated for it. The necessary information is recorded there. This is our object. A pointer to the object is saved to access it. It contains a reference to the memory area where the object is stored. Consequently, when accessing the object, we take the pointer, navigate to the desired memory location, and read the necessary information.

When we copy a pointer to an object, we don't create a new object, we just make a copy of the reference. Therefore, when someone makes changes to the content of the object, we will also see these changes by accessing the object through our pointer. Whether this is good or bad depends on the method of using the object. When we need synchronization of operations with an object from different sources, that's a good thing. Everyone will refer to the same object. This means there is no need to synchronize data in different storages. Moreover, a pointer requires fewer resources than creating a new object. But when we need to protect some data against changes, it is better to create a new object and copy the necessary information.

   if(!InsertBuffer(m_cMemorysmemoryfalse))
     {
      delete hidden;
      return false;
     }

   if(!InsertBuffer(m_cHiddenStateshiddenfalse))
      return false;
//---
   return true;
  }

After successfully saving all the necessary information in the stack, we exit the method with a positive result.

Congratulations! We've reached the end of the forward pass method. It may not be the simplest, but I hope my comments have helped you understand its algorithm and the idea behind the process. However, we still have some open questions in the form of auxiliary methods.

As we considered the algorithm of the feed-forward pass method, we mentioned the ClearBuffer method. Here, everything is quite simple and straightforward. The method receives a pointer to the stack in the parameters. As always, at the beginning of the method, we check the validity of the received pointer. After successfully passing the pointer check, we verify the buffer size. If the size of the buffer exceeds the user-specified size, we delete the last elements. By doing so, we ensure that the buffer size fits within the specified limits. As you can see, the whole code of the method fits literally into five lines.

void CNeuronLSTM::ClearBuffer(CArrayObj *buffer)
  {
   if(!buffer)
      return;
   int total = buffer.Total();
   if(total > m_iDepth + 1)
      buffer.DeleteRange(m_iDepth + 1total);
  }

Then we discussed the InsertBuffer method that adds a buffer to the stack. This method has three parameters, the last of which has a default value and is not mandatory to specify when calling the method:

  • CArrayObj *array — a pointer to the stack for adding a buffer.
  • CBufferType *element — a pointer to the buffer to be added.
  • bool create_new — a logical variable indicating the need to create a duplicate buffer. By default, a duplicate buffer is created.

As a result of the operations, the method returns a boolean value indicating the status of the operations.

As always, at the beginning of the method, we check if the obtained pointers are valid. Here, there is one nuance. First, we check the pointer to the buffer to be added to the stack. With an invalid pointer, we have nothing to add to the stack. Naturally, in such a situation, we exit the method with a negative result.

However, if the pointer to the stack turns out to be invalid, we will first attempt to create a new stack. Only after an unsuccessful attempt, we will exit the method with a negative result. But if we manage to create a new stack, we will continue working in the standard mode.

bool CNeuronLSTM::InsertBuffer(CArrayObj *&array,
                               CBufferType *element,
                               bool create_new = true)
  {
//--- control block
   if(!element)
      return false;
   if(!array)
     {
      array = new CArrayObj();
      if(!array)
         return false;
     }

Next, we split the algorithm into two separate branches depending on whether a duplicate buffer needs to be created. If a duplicate buffer is needed, we first create a new instance of the buffer object and immediately check the result of the operation using the obtained pointer to the object.

   if(create_new)
     {
      CBufferType *buffer = new CBufferType();
      if(!buffer)
         return false;

Then we transfer the contents of the source buffer to the new buffer. Only after that, we will add the pointer to the new buffer to the stack. Again, we add new elements to the stack with an index of 0.

      buffer.m_mMatrix = element.m_mMatrix;
      if(!array.Insert(buffer0))
        {
         delete buffer;
         return false;
        }
     }

If we don't need to create a new instance of the buffer, then things are much simpler here. We simply add the pointer to the buffer received as a parameter to the stack.

   else
     {
      if(!array.Insert(element0))
        {
         delete element;
         return false;
        }
     }

After adding a new element to the stack, we will check its size and remove excessive history. For this, we will use the ClearBuffer method.

--- remove unnecessary history from the buffer
   ClearBuffer(array);
//---
   return true;
  }

After the operations are complete, we exit the method with a positive result.

We have thoroughly covered the feed-forward pass algorithm and the methods involved in it. Next, let's consider the backpropagation pass.