//+------------------------------------------------------------------+
//|                                                    MatrixNet.mqh |
//|                                    Copyright (c) 2023, Marketeer |
//|                          https://www.mql5.com/ru/articles/12187/ |
//+------------------------------------------------------------------+
#include <Graphics/Graphic.mqh>
#define PUSH(A,V) (A[ArrayResize(A, ArrayRange(A, 0) + 1, ArrayRange(A, 0) * 2) - 1] = V)

// In your source code you can enable RPROP mode (recommended)
// by placing the following macro in front of #include <MatrixNet.mqh>
// 
// #define BATCH_PROP

//+------------------------------------------------------------------+
//| Main class for backpropagation NN on matrices                    |
//+------------------------------------------------------------------+
class MatrixNet
{
protected:
   const int n;      // number of layers with weights (excluding input layer)
   matrix weights[/* n */];
   matrix outputs[/* n + 1 */];
   ENUM_ACTIVATION_FUNCTION af; // default activation function for all layers
   ENUM_ACTIVATION_FUNCTION of; // output layer activation function (if specified)
   bool ready;
   int dropOutRate;
   
public:
   // data stats and custom info
   struct Stats
   {
      double bestLoss;
      int bestEpoch;
      int trainingSet;
      int validationSet;
      int epochsDone;
   };
   
   Stats getStats() const
   {
      return stats;
   }
   
protected:
   // save best weights every time we got new minimum of loss
   matrix bestWeights[];
   Stats stats;
   
   #ifdef BATCH_PROP
   matrix speed[];
   matrix deltas[];
   #else
   double speed;
   #endif
   
   void allocate()
   {
      ArrayResize(weights, n);
      ArrayResize(outputs, n + 1);
      ArrayResize(bestWeights, n);
      dropOutRate = 0;
      #ifdef BATCH_PROP
      ArrayResize(speed, n);
      ArrayResize(deltas, n);
      plus = 1.1;
      minus = 0.1;
      max = 50;
      min = 0.0;
      #endif
   }
   
   template<typename T>
   struct SubArray
   {
      T data[];
   };
   
   class DropOutState
   {
      SubArray<uint> indices[];
      matrix weights[];
      const int percent;
   public:
      DropOutState(const int p = 10 /* subject of practical selection */): percent(p) { }
      
      bool restoreState(matrix &parent[], const bool cleanup = true)
      {
         const int n = ArraySize(parent);
         
         if(ArraySize(weights) == n)
         {
            for(int i = 0; i < n; ++i)
            {
               for(int j = 0; j < ArraySize(indices[i].data); ++j)
               {
                  parent[i].Flat(indices[i].data[j], weights[i].Flat(indices[i].data[j]));
               }
               if(cleanup) ArrayResize(indices[i].data, 0);
            }
            return true;
         }
         return false;
      }
      
      bool switchState(matrix &parent[])
      {
         const int n = ArraySize(parent);
         
         if(ArraySize(weights) == 0)
         {
            ArrayResize(weights, n);
            ArrayResize(indices, n);
         }
         else if(!restoreState(parent))
         {
            return false;
         }
         
         for(int i = 0; i < n; ++i)
         {
            weights[i].Assign(parent[i]); // save current state
            const int m = (int)(parent[i].Rows() * parent[i].Cols());
            int k = 0;
            while(k++ < m * percent / 100)
            {
               const uint p = (rand() | (rand() << 16)) % m;
               parent[i].Flat(p, 0);
               PUSH(indices[i].data, p);
            }
         }
         return true;
      }
   };
   
public:
   MatrixNet(const int &layers[], const ENUM_ACTIVATION_FUNCTION f1 = AF_TANH,
      const ENUM_ACTIVATION_FUNCTION f2 = AF_NONE):
      ready(false), af(f1), of(f2), n(ArraySize(layers) - 1)
   {
      if(n < 2) return;
      
      allocate();
      for(int i = 1; i <= n; ++i)
      {
         // NB: weights are transposed, that is [row][column] hold [synapse][neuron]
         weights[i - 1].Init(layers[i - 1] + 1, layers[i]);
         #ifdef BATCH_PROP
         speed[i - 1] = weights[i - 1];
         deltas[i - 1] = weights[i - 1];
         #endif
      }
      ready = true;
      randomize();
   }
   
   MatrixNet(const matrix &w[], const ENUM_ACTIVATION_FUNCTION f1 = AF_TANH,
      const ENUM_ACTIVATION_FUNCTION f2 = AF_NONE):
      ready(false), af(f1), of(f2), n(ArraySize(w))
   {
      if(n < 2) return;

      allocate();
      for(int i = 0; i < n; ++i)
      {
         weights[i] = w[i];
         #ifdef BATCH_PROP
         speed[i] = weights[i];
         deltas[i] = weights[i];
         #endif
      }
      
      ready = true;
   }
   
   bool isReady() const
   {
      return ready;
   }
   
   void enableDropOut(const uint percent = 10 /* 0 means disable */)
   {
      dropOutRate = (int)percent;
   }
   
   void setActivationFunction(const ENUM_ACTIVATION_FUNCTION f1, ENUM_ACTIVATION_FUNCTION f2 = AF_NONE)
   {
     af = f1;
     of = f2;
   }

   ENUM_ACTIVATION_FUNCTION getActivationFunction(const bool output = false) const
   {
     return output ? of : af;
   }
   
   bool getWeights(matrix &array[]) const
   {
      if(!ready) return false;
      
      ArrayResize(array, n);
      for(int i = 0; i < n; ++i)
      {
         array[i] = weights[i];
      }
      
      return true;
   }
   
   bool setWeights(matrix &array[])
   {
      if(!ready) return false;
      
      if(ArraySize(array) != n)
      {
         PrintFormat("Number of layers mismatches: got %d, expected %d",
            ArraySize(array), n);
         return false;
      }
      
      for(int i = 0; i < n; ++i)
      {
         if(array[i].Rows() != weights[i].Rows()
         || array[i].Cols() != weights[i].Cols())
         {
            PrintFormat("%d-th layer dimensions mismatch: got %dx%d, expected %dx%d",
               i, array[i].Rows(), array[i].Cols(), weights[i].Rows(), weights[i].Cols());
            return false;
         }
      }
      
      ArraySwap(array, weights);
      
      return true;
   }

   bool getBestWeights(matrix &array[]) const
   {
      if(!ready) return false;
      if(!n || !bestWeights[0].Rows()) return false;
      
      ArrayResize(array, n);
      for(int i = 0; i < n; ++i)
      {
         array[i] = bestWeights[i];
      }
      
      return true;
   }
   
   // NB: change values to appropriate distribution for specific activation function
   void randomize(const double from = -0.5, const double to = +0.5)
   {
      if(!ready) return;

      for(int i = 0; i < n; ++i)
      {
         weights[i].Random(from, to);
      }
   }
   
   double train(const matrix &data, const matrix &target,
      const matrix &validation, const matrix &check,
      const int epochs = 1000, const double accuracy = 0.001,
      const ENUM_LOSS_FUNCTION lf = LOSS_MSE)
   {
      if(!ready) return NaN();
      
      #ifdef BATCH_PROP
      for(int i = 0; i < n; ++i)
      {
         speed[i].Fill(accuracy); // will adjust the speeds on the fly
         deltas[i].Fill(0);
      }
      #else
      speed = accuracy;
      #endif
      
      double mse = DBL_MAX;
      double msev = DBL_MAX;
      double msema = 0;       // averaged training MSE
      double msemap = 0;      // averaged training MSE on previous epoch
      double msevma = 0;      // averaged validation MSE
      double msevmap = 0;     // averaged validation MSE on previous epoch
      double ema = 0;         // exponentional averaging coefficient
      int p = 0, grow = 0;    // ema period
      const int scale = (int)(data.Rows() / (validation.Rows() + 1)) + 1;
      
      p = (int)sqrt(epochs); // FIXME: rule of thumb - reconsider as appropriate
      ema = 2.0 / (p + 1);
      PrintFormat("EMA for early stopping: %d (%f)", p, ema);
      
      stats.bestLoss = DBL_MAX;
      stats.bestEpoch = -1;
      
      DropOutState state(dropOutRate);

      int ep = 0;
      for(; ep < epochs; ep++)
      {
         // NB: on each epoch entire dataset is processed as is,
         // no batches or shuffling - implement yourself
         if(validation.Rows() && check.Rows())
         {
            // if validation is enabled, run it before normal/training pass
            msev = test(validation, check, lf);
            // smooth error stat through epochs
            msevma = (msevma ? msevma : msev) * (1 - ema) + ema * msev;
         }
         
         if(dropOutRate > 0)
         {
            state.restoreState(weights);
         }
         
         mse = test(data, target, lf);  // invokes feedForward(data)
         msema = (msema ? msema : mse) * (1 - ema) + ema * mse;
         
         const double candidate = (msev != DBL_MAX) ? msev : mse;
         if(candidate < stats.bestLoss)
         {
            stats.bestLoss = candidate;
            stats.bestEpoch = ep;
            // get all 'weights' (which can be partially dropped) into 'bestWeights'
            for(int i = 0; i < n; ++i)
            {
               bestWeights[i].Assign(weights[i]);
            }
         }
         
         if(!progress(ep, epochs, mse, msev, msema, msevma))
         {
            PrintFormat("Interrupted by user at epoch %d", ep);
            break;
         }
         
         if(!MathIsValidNumber(mse))
         {
            PrintFormat("NaN at epoch %d", ep);
            break; // will return NaN as error indication
         }

         if(ep > p && candidate > stats.bestLoss * 10)
         {
            PrintFormat("Too big errors at epoch %d", ep);
            break;
         }

         if(msema > msemap)
         {
            if(++grow > p)
            {
               PrintFormat("Stop by growing error at epoch %d", ep);
               break;
            }
         }
         else
         {
            grow = 0;
         }
         
         if(msevmap != 0 && ep > p && msevma > msevmap + scale * (msemap - msema))
         {
            // skip first p epochs to accumulate values for smoothing
            PrintFormat("Stop by validation at %d, v: %f > %f, t: %f vs %f", ep, msevma, msevmap, msema, msemap);
            break;
         }
         
         msevmap = msevma;
         msemap = msema;
         
         if(mse <= accuracy)
         {
            PrintFormat("Done by accuracy limit %f at epoch %d", accuracy, ep);
            break;
         }
         
         if(dropOutRate > 0)
         {
            state.switchState(weights);
         }
         
         if(!backProp(target))
         {
            mse = NaN(); // error flag
            break;
         }
      }
      
      if(ep == epochs)
      {
         PrintFormat("Done by epoch limit %d with accuracy %f", ep, mse);
      }
      
      stats.trainingSet = (int)data.Rows();
      stats.validationSet = (int)validation.Rows();
      stats.epochsDone = ep;
      
      if(dropOutRate > 0) state.restoreState(weights);
      return mse;
   }
   
   double train(const matrix &data, const matrix &target,
      const int epochs = 1000, const double accuracy = 0.001,
      const ENUM_LOSS_FUNCTION lf = LOSS_MSE)
   {
      matrix dummy = {}, fake = {};
      return train(data, target, dummy, fake, epochs, accuracy, lf);
   }
   
   virtual bool progress(const int epoch, const int total,
      const double error, const double valid = DBL_MAX,
      const double ma = DBL_MAX, const double mav = DBL_MAX)
   {
      static uint trap;
      if(GetTickCount() > trap) // by default log every second
      {
         PrintFormat("Epoch %d of %d, loss %.5f%s%s%s", epoch, total, error,
            ma == DBL_MAX ? "" : StringFormat(" ma(%.5f)", ma),
            valid == DBL_MAX ? "" : StringFormat(", validation %.5f", valid),
            valid == DBL_MAX ? "" : StringFormat(" v.ma(%.5f)", mav));
         trap = GetTickCount() + 1000;
      }
      return !IsStopped(); // true keeps running, false will break the training loop
   }
   
   bool feedForward(const matrix &data)
   {
      if(!ready) return false;

      if(data.Cols() != weights[0].Rows() - 1)
      {
         PrintFormat("Column number in data %d <> Inputs layer size %d",
            data.Cols(), weights[0].Rows() - 1);
         return false;
      }
      
      outputs[0] = data;
      for(int i = 0; i < n; ++i)
      {
         // extend each layer with 1 neuron for bias (except for the last layer)
         if(!outputs[i].Resize(outputs[i].Rows(), weights[i].Rows()) ||
            !outputs[i].Col(vector::Ones(outputs[i].Rows()), weights[i].Rows() - 1))
            return false;
         // propagate signal from i-th layer to (i+1)-th layer
         matrix temp = outputs[i].MatMul(weights[i]);
         if(!temp.Activation(outputs[i + 1], i < n - 1 ? af : of))
            return false;
      }
      
      return true;
   }
   
   matrix getResults(const int layer = -1) const
   {
      static const matrix empty = {};
      if(!ready) return empty;
      
      if(layer == -1) return outputs[n];
      if(layer < -1 || layer > n) return empty;
      
      return outputs[layer];
   }
   
   /*
   
      LEGEND for error (loss) backpropagation
      
      last layer:
         loss = (y - t) * derivative(y)
      other layers:
         loss = loss[y[+1]] * w'[y[+1]] * derivative(y)
      update:
         weight += niu * loss * y[-1]
      
      where y is a neuron state in current layer, or
         a reference to connected neuron
         from previous layer y[-1] or next layer y[+1]
      
      NB: lines marked by //* comprise a bugfix released after the article:
      it turned out that the method Derivative() accepts input values of activation functions,
      not output values of activation functions as it was initially supposed
       
   */
   bool backProp(const matrix &target)
   {
      if(!ready) return false;
   
      if(target.Rows() != outputs[n].Rows() ||
         target.Cols() != outputs[n].Cols())
         return false;
      
      // output layer
      matrix temp;
      //*if(!outputs[n].Derivative(temp, of))
      //*  return false;
      if(!outputs[n - 1].MatMul(weights[n - 1]).Derivative(temp, of))
         return false;
      matrix loss = (outputs[n] - target) * temp; // data record per row
     
      for(int i = n - 1; i >= 0; --i) // for each layer except output
      {
         //*// remove unusable pseudo-errors for neurons, added as constant bias source
         //*// (in all layers except for the last (where it wasn't added))
         //*if(i < n - 1) loss.Resize(loss.Rows(), loss.Cols() - 1);
         #ifdef BATCH_PROP
         matrix delta = speed[i] * outputs[i].Transpose().MatMul(loss);
         adjustSpeed(speed[i], delta * deltas[i]);
         deltas[i] = delta;
         #else
         matrix delta = speed * outputs[i].Transpose().MatMul(loss);
         #endif
         
         // NB: i-th index in outputs[] corresponds to
         // the layer of neurons defined by (i-1)-th index in weights[],
         // because input layer (outputs[0]) does not have weights,
         // in other words, weights[0] produce outputs[1],
         // weights[1] produce outputs[2], etc.
         
         //*if(!outputs[i].Derivative(temp, af))
         //*   return false;
         //*loss = loss.MatMul(weights[i].Transpose()) * temp;
         if(i > 0) // backpropagate loss to previous layers
         {
            if(!outputs[i - 1].MatMul(weights[i - 1]).Derivative(temp, af))
               return false;
            matrix mul = loss.MatMul(weights[i].Transpose());
            // remove unusable pseudo-errors for neurons, added as constant bias source
            // (in all layers except for the last (where it wasn't added))
            mul.Resize(mul.Rows(), mul.Cols() - 1);
            loss = mul * temp;
         }
         
         weights[i] -= delta;
      }
      return true;
   }

   double test(const matrix &data, const matrix &target, const ENUM_LOSS_FUNCTION lf = LOSS_MSE)
   { 
      if(!ready || !feedForward(data)) return NaN();
      
      return outputs[n].Loss(target, lf);
   }
   
   static double NaN() // used to signal an error packed in double
   {
      return MathArcsin(2.0); // usefull to trace a problem via breakpoint
   }

   #ifdef BATCH_PROP

   void setupSpeedAdjustment(const double up, const double down,
      const double high, const double low)
   {
      plus = up;
      minus = down;
      max = high;
      min = low;
   }
   
protected:
   double plus;
   double minus;
   double max;
   double min;

   void adjustSpeed(matrix &subject, const matrix &product)
   {
      for(int i = 0; i < (int)product.Rows(); ++i)
      {
         for(int j = 0; j < (int)product.Cols(); ++j)
         {
            if(product[i][j] > 0)
            {
               subject[i][j] *= plus;
               if(subject[i][j] > max) subject[i][j] = max;
            }
            else if(product[i][j] < 0)
            {
               subject[i][j] *= minus;
               if(subject[i][j] < min) subject[i][j] = min;
            }
         }
      }
   }
   #endif
};

//+------------------------------------------------------------------+
//| Helper custom graphic with published work area                   |
//+------------------------------------------------------------------+
class CGraphicView: public CGraphic
{
public:
   int getRight() const
   {
      return m_right;
   }
   int getLeft() const
   {
      return m_left;
   }
   int getTop() const
   {
      return m_up;
   }
   int getBottom() const
   {
      return m_down;
   }
};

//+-------------------------------------------------------------------+
//| Backpropagation NN on matrices with visualization of MSE progress |
//+-------------------------------------------------------------------+
class MatrixNetVisual: public MatrixNet
{
   CGraphicView graphic;
   CCurve *c[5];
   double p[], x[], y[], z[], q[], b[];
   const string objname;
   const double nan;
   double amplitude;

   // prepare chart object for drawing
   void graph()
   {
      ulong width = ChartGetInteger(0, CHART_WIDTH_IN_PIXELS);
      ulong height = ChartGetInteger(0, CHART_HEIGHT_IN_PIXELS);

      bool res = false;
      if(ObjectFind(0, objname) >= 0)
         res = graphic.Attach(0, objname);
      else
         res = graphic.Create(0, objname, 0, 0, 0, (int)(width - 0), (int)(height - 0));
      if(!res)
         return;

      c[0] = graphic.CurveAdd(p, x, CURVE_LINES, "Training");
      c[1] = graphic.CurveAdd(p, y, CURVE_LINES, "Validation");
      c[2] = graphic.CurveAdd(p, z, CURVE_LINES, "Val.EMA");
      c[3] = graphic.CurveAdd(p, q, CURVE_LINES, "Train.EMA");
      c[4] = graphic.CurveAdd(p, b, CURVE_POINTS, "Best/Minimum");
      ArrayResize(b, 1);
      amplitude = 0;
      graphic.XAxis().AutoScale(false);
      graphic.YAxis().AutoScale(false);
   }
   
   void plot()
   {
      c[0].Update(p, x);
      c[1].Update(p, y);
      c[2].Update(p, z);
      c[3].Update(p, q);
      double point[1] = {stats.bestEpoch};
      b[0] = stats.bestLoss;
      c[4].Update(point, b);

      const int size = ArraySize(p) - 1;
      graphic.CalculateMaxMinValues();
      graphic.XAxis().Min(0);
      graphic.YAxis().Min(0);
      // find max values on the fly at every last added point
      const double range = y[size] != DBL_MAX ? MathMax(y[size], x[size]) : x[size];
      if(range > amplitude)
      {
         amplitude = range;
      
         double ystep = MathPow(10, MathCeil(MathLog10(amplitude))) / 20;
         if(ystep != 0 && amplitude / ystep < 5) ystep /= 2;
         graphic.YAxis().Max(ystep != 0 ? ystep * (MathCeil(amplitude / ystep)) : 1);
         graphic.YAxis().DefaultStep(ystep);
      }
      
      double xstep = MathPow(10, MathCeil(MathLog10(p[size]))) / 20;
      if(xstep != 0 && p[size] / xstep < 5) xstep /= 2;
      graphic.XAxis().Max(xstep != 0 ? xstep * (MathCeil(p[size] / xstep)) : 1);
      graphic.XAxis().DefaultStep(xstep);

      graphic.CurvePlotAll();
      graphic.TextAdd(graphic.Width() - graphic.getRight() - 5, graphic.getTop() + 5,
         "MSE error (Loss) by Epoch (Cycle)", clrBlack, TA_RIGHT | TA_TOP);
      graphic.Update();
   }

public:
   MatrixNetVisual(const int &layers[], const ENUM_ACTIVATION_FUNCTION f1 = AF_TANH,
      const ENUM_ACTIVATION_FUNCTION f2 = AF_NONE): MatrixNet(layers, f1, f2), objname("BPNNERROR"), nan(NaN())
   {
      graph();
   }

   MatrixNetVisual(const matrix &w[], const ENUM_ACTIVATION_FUNCTION f1 = AF_TANH,
      const ENUM_ACTIVATION_FUNCTION f2 = AF_NONE): MatrixNet(w, f1, f2), objname("BPNNERROR"), nan(NaN())
   {
      graph();
   }
   
   ~MatrixNetVisual()
   {
      if(!MQLInfoInteger(MQL_TESTER))
      {
         graphic.Destroy();
      }
   }
   
   CGraphicView *view() const
   {
      return (CGraphicView *)&graphic;
   }

   virtual bool progress(const int epoch, const int total,
      const double error, const double valid = DBL_MAX,
      const double ma = DBL_MAX, const double mav = DBL_MAX) override
   {
      // accumulate and draw graph of error, valid, ma values
      PUSH(p, epoch);
      PUSH(x, error);
      if(valid != DBL_MAX) PUSH(y, valid); else PUSH(y, nan);
      if(ma != DBL_MAX) PUSH(q, ma); else PUSH(q, nan);
      if(mav != DBL_MAX) PUSH(z, mav); else PUSH(z, nan);
      plot();
      
      return MatrixNet::progress(epoch, total, error, valid, ma, mav);
   }
};

/* EXAMPLE:

bool CreateData(matrix &data, matrix &target, const int count) 
{ 
   if(!data.Init(count, 3) || !target.Init(count, 1)) return false; 
   data.Random(-10, 10);                      
   vector X1 = MathPow(data.Col(0) + data.Col(1) + data.Col(2), 2); 
   vector X2 = MathPow(data.Col(0), 2) + MathPow(data.Col(1), 2) + MathPow(data.Col(2), 2); 
   if(!target.Col(X1 / X2 / 3, 0)) return false;
   return true; 
} 

void OnStart()
{
   const int layers[] = {3, 21, 15, 1};
   MatrixNetVisual net(layers);
   matrix data, target;
   CreateData(data, target, 100);
   matrix valid, test;
   CreateData(valid, test, 25);
   
   // NB: in practice you should normalize and clean up data from outliers
   // before training (here we generate artificially ideal data)
   
   Print(net.train(data, target, valid, test, 1000, 0.0001));
   //Print(net.train(data, target, 1000, 0.00001));
   matrix w[];
   if(net.getBestWeights(w))
   {
      // for(int i = 0; i < ArraySize(w); ++i) Print(w[i]); // debug
      MatrixNet net2(w);
      if(net2.isReady())
      {
         Print("Copy: ", net2.test(data, target));
      }
   }
}
*/
//+-------------------------------------------------------------------+
