//+------------------------------------------------------------------+ //| MatrixNet.mqh | //| Copyright (c) 2023, Marketeer | //| https://www.mql5.com/ru/articles/12187/ | //+------------------------------------------------------------------+ #include #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 // // #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 struct SubArray { T data[]; }; class DropOutState { SubArray 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)); } } } */ //+-------------------------------------------------------------------+