Files

768 lines
23 KiB
Plaintext
Raw Permalink Normal View History

2025-02-07 19:17:11 +03:30
//+------------------------------------------------------------------+
//| 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));
}
}
}
*/
//+-------------------------------------------------------------------+