5.3.2.3 Методы работы с файлами

Мы продолжаем работу над нашим классом реализации модели GPT. Фактически мы уже реализовали функционал данной модели в методах нашего класса CNeuronGPT. В предыдущих разделах мы разобрали методы инициализации объекта, а также выстроили процессы прямого и обратного проходов. Указанного функционала вполне достаточно для создания тестовой модели и даже можно провести ряд тестов работоспособности модели.

Но мы уже не раз говорили о важности методов работы с файлами для промышленной эксплуатации любой модели нейронной сети. Основную значимость данному процессу придают стоимость процесса обучения модели, ведь этот процесс требует затрат как времени, так и ресурсов. Часто подобные затраты бывают довольно высокие. Поэтому есть огромное желание обучить модель один раз и в последующем использовать ее с максимальной нагрузкой в минимально возможные сроки.

Конечно, изменчивость финансовых рынков не оставляет нам надежды на бесконечно долгое использование однажды обученной модели. Но и здесь, даже с изменчивостью окружающей среды, дообучение модели в новых условиях потребует меньших затрат ресурсов и времени, чем обучение модели полностью со случайных весовых коэффициентов.

Поэтому давайте продолжим нашу работу и реализуем методы работы с файлами. Как всегда, начнем с метода сохранения данных в файл CNeuronGPT::Save.

Приступая к работе над методом сохранения данных,  мы как обычно бросаем критический взгляд на структуру нашего класса и оцениваем необходимость сохранения данных каждого объекта.

class CNeuronGPT    :  public CNeuronBase
  {
protected:
   CArrayLayers      m_cQuerys;
   CArrayLayers      m_cKeys;
   CArrayLayers      m_cValues;
   CArrayLayers      m_cScores;
   CArrayLayers      m_cAttentionOut;
   CArrayLayers      m_cW0;
   CArrayLayers      m_cFF1;
   CArrayLayers      m_cFF2;
   //---
   int               m_iLayers;
   int               m_iWindow;
   int               m_iUnits;
   int               m_iKeysSize;
   int               m_iHeads;
   CBufferType       m_dStd[];
   int               m_iCurrentPosition;
   int               m_iScoreTemp;
 
   virtual bool      NormlizeBuffer(CBufferType *bufferCBufferType *std,
                                                               uint std_shift);
   virtual bool      NormlizeBufferGradient(CBufferType *output
                      CBufferType *gradientCBufferType *stduint std_shift);
public:
                     CNeuronGPT(void);
                    ~CNeuronGPT(void);
   //---
   virtual bool      Init(const CLayerDescription *descoverride;
   virtual bool      SetOpenCL(CMyOpenCL *opencloverride;
   virtual bool      FeedForward(CNeuronBase *prevLayeroverride;
   virtual bool      CalcHiddenGradient(CNeuronBase *prevLayeroverride;
   virtual bool      CalcDeltaWeights(CNeuronBase *prevLayerbool readoverride;
   virtual bool      UpdateWeights(int batch_sizeTYPE learningRate,
                                           VECTOR &BetaVECTOR &Lambdaoverride;
   //---
   virtual int       GetUnits(voidconst { return m_iUnits;   }
   virtual int       GetLayers(voidconst { return m_iLayers; }
   //--- методы работы с файлами
   virtual bool      Save(const int file_handleoverride;
   virtual bool      Load(const int file_handleoverride;
   //--- метод идентификации объекта
   virtual int       Type(voidoverride  const { return(defNeuronGPT);  }
  };

И тут мы понимаем, что кроме констант наш класс содержит только коллекции объектов. Трудозатраты на повторное создание объектов коллекций с полным описанием их структуры будут гораздо выше возможной экономии ресурсов дискового пространства. Поэтому мы организуем сохранение всех коллекций в файл данных для восстановления модели.

В параметрах данный метод получает хендл файла для сохранения данных. Чтобы избежать дублирующих контролей и сократить общий объем кода программы, мы не будем проверять полученный хендл. Вместо этого мы просто вызовем аналогичный метод родительского класса, в который и передадим полученный хендл. Преимущества такого подхода очевидны. Одной командой мы проверяем полученный хендл и сохраняем данные объектов, унаследованных от родительского класса. Одной проверкой результата выполнения метода родительского класса мы контролируем весь указанный процесс.

bool CNeuronGPT::Save(const int file_handle)
  {
//--- вызов метода родительского класса
   if(!CNeuronBase::Save(file_handle))
      return false;

После успешного выполнения метода родительского класса мы сохраняем в файл константы нашего метода:

  • m_iLayers — количество вложенных нейронных слоев блока GPT;
  • m_iWindow — размер окна исходных данных (размер вектора описания одного элемента последовательности исходных данных);
  • m_iKeysSize — размер вектора описания одного элемента тензора ключей Keys;
  • m_iHeads — количество используемых голов внимания;
  • m_iUnits — количество элементов в последовательности;
  • m_iCurrentPosition — позиция текущего анализируемого элемента.

//--- сохраняем константы
   if(FileWriteInteger(file_handlem_iLayers) <= 0)
      return false;
   if(FileWriteInteger(file_handlem_iWindow) <= 0)
      return false;
   if(FileWriteInteger(file_handlem_iKeysSize) <= 0)
      return false;
   if(FileWriteInteger(file_handlem_iHeads) <= 0)
      return false;
   if(FileWriteInteger(file_handlem_iUnits) <= 0)
      return false;
   if(FileWriteInteger(file_handlem_iCurrentPosition) <= 0)
      return false;

Сохранение позиции текущего анализируемого элемента необходимо для правильной работы стеков ключей Key и значений Value. Впрочем в условиях промышленной эксплуатации я бы рекомендовал перед использованием модели последовательно подать ей на вход данные в объеме, достаточном для полного заполнения стеков. Такой подход позволит контролировать процесс загрузки данных в модель и исключить риск возможных пропусков, которые потенциально могут отрицательно сказаться на точности работы модели на начальном этапе после загрузки данных. Конечно, работа модели выровняется после полного заполнения стека. Но риск получения убытков до этого момента повышается.

Далее мы поочередно проверяем указатели на объекты всех наших коллекций и вызываем их методы сохранения данных.

//--- вызываем аналогичный метод для всех коллекций внутренних слоев
   if(!m_cQuerys.Save(file_handle))
      return false;
   if(!m_cKeys.Save(file_handle))
      return false;
   if(!m_cValues.Save(file_handle))
      return false;
   if(!m_cScores.Save(file_handle))
      return false;
   if(!m_cAttentionOut.Save(file_handle))
      return false;
   if(!m_cW0.Save(file_handle))
      return false;
   if(!m_cFF1.Save(file_handle))
      return false;
   if(!m_cFF2.Save(file_handle))
      return false;
//---
   return true;
  }

После этого выходим из метода сохранения данных.

Что ж, метод сохранения объекта нашего класса мы создали. Теперь можем перейти к работе над методом восстановления объекта из данных, записанных в файл. Напомню, что основным требованием к методам восстановления работоспособности объектов из сохраненных данных является считывание данных в строгом соответствии с последовательностью их записи.

Как и метод записи в файл, наш метод загрузки данных CNeuronGPT::Load в параметрах получает хендл файла, содержащего данные для считывания. Точно также, как и при записи данных, сначала мы вызываем аналогичный метод родительского класса. Во-первых, мы выполняем чтение данных в строгом соответствии с последовательностью записи. Во-вторых, используем идею, озвученную при изучении метода записи данных, т.е. используем контроли, реализованные в методе родительского класса, и исключаем их дублирование. Конечно, перед тем как двигаться дальше, мы проверяем результат выполнения операций родительского метода.

bool CNeuronGPT::Load(const int file_handle)
  {
//--- вызов метода родительского класса
   if(!CNeuronBase::Load(file_handle))
      return false;

После успешного выполнения метода родительского класса мы считываем константы параметров работы нашего блока. Считывание их значений осуществляется в порядке их записи. После считывания значения констант не забываем изменить размер динамического массива для записи стандартных отклонений, используемых при нормализации результатов работы нашего блока. Размер массива должен быть достаточным для хранения данных со всех вложенных нейронных слоев. В противном случае мы рискуем получить критическую ошибку выхода за размеры массива в процессе выполнения программы.

//--- считываем константы из файла
   m_iLayers = FileReadInteger(file_handle);
   m_iWindow = FileReadInteger(file_handle);
   m_iKeysSize = FileReadInteger(file_handle);
   m_iHeads = FileReadInteger(file_handle);
   m_iUnits = FileReadInteger(file_handle);
   m_iCurrentPosition = FileReadInteger(file_handle);
   if(ArrayResize(m_dStdm_iLayers) <= 0)
      return false;
   for(int i = 0i < m_iLayersi++)
      if(!m_dStd[i].BufferInit(121))
         return false;;

Дальше нам остается лишь загрузить данные наших коллекций объектов. Но прежде чем вызвать метод загрузки данных коллекции объектов, нужно убедиться в актуальности указателя на объект коллекции и при необходимости создать новый экземпляр объекта коллекции. Только потом можно вызывать метод загрузки данных. Конечно, не забываем, что порядок загрузки объектов идет в строгом соответствии с порядком их записи. Также контролируем процесс загрузки данных на каждой итерации.

//--- вызываем аналогичный метод для всех коллекций внутренних слоев
   if(!m_cQuerys.Load(file_handle))
      return false;
   if(!m_cKeys.Load(file_handle))
      return false;
   if(!m_cValues.Load(file_handle))
      return false;
   if(!m_cScores.Load(file_handle))
      return false;
   if(!m_cAttentionOut.Load(file_handle))
      return false;
   if(!m_cW0.Load(file_handle))
      return false;
   if(!m_cFF1.Load(file_handle))
      return false;
   if(!m_cFF2.Load(file_handle))
      return false;

После загрузки всех объектов мы создадим еще один цикл и переформатируем буферы результатов всех созданных объектов. В данном случае мы не осуществляем проверку действительности указателей на объект — в предыдущих итерациях все эти объекты загружали данные с файла, а значит, были созданы и проверены.

//--- переформатируем матрицы результатов
   for(int i = 0i < m_iLayersi++)
     {
      CNeuronBasetemp = m_cKeys.At(i);
      if(!temp.GetOutputs().Reshape(m_iUnitsm_iKeysSize * m_iHeads))
         return false;
      temp = m_cValues.At(i);
      if(!temp.GetOutputs().Reshape(m_iUnitsm_iKeysSize * m_iHeads))
         return false;
      temp = m_cScores.At(i);
      if(!temp.GetOutputs().Reshape(m_iHeadsm_iUnits))
         return false;
      temp = m_cAttentionOut.At(i);
      if(!temp.GetOutputs().Reshape(m_iHeadsm_iKeysSize))
         return false;
     }

В завершение метода мы осуществим подмену буферов и завершаем его работу.

//--- осуществляем подмену буферов данных для исключения излишнего копирования
   CNeuronBase *last = m_cFF2.At(m_cFF2.Total() - 1);
   if(!m_cOutputs)
      delete m_cOutputs;
   m_cOutputs = last.GetOutputs();
   if(!m_cGradients)
      delete m_cGradients;
   m_cGradients = last.GetGradients();
//---
   return true;
  }

Теперь, когда созданы методы работы с файлами, мы можем двигаться дальше. Дальше у нас по плану создание возможности выполнения параллельных математических операций с использованием технологии OpenCL.