Warrior_EA/AI/Impl/NetForward.mqh

597 lines
25 KiB
MQL5
Raw Permalink Normal View History

//+------------------------------------------------------------------+
//| NetForward.mqh |
//| |
//| CNet inference and training passes: feedForward, backProp, |
//| getResults, logit adjustment. |
//| |
//| Included from AI\Network.mqh AFTER every class declaration - |
//| bodies only, no declarations. Relocation is behaviour-neutral by |
//| construction: nothing here is reachable until Network.mqh ends. |
//+------------------------------------------------------------------+
#ifndef WARRIOR_AI_IMPL_NETFORWARD_MQH
#define WARRIOR_AI_IMPL_NETFORWARD_MQH
//+------------------------------------------------------------------+
//| |
//+------------------------------------------------------------------+
bool CNet::feedForward(CArrayDouble *inputVals)
{
if(CheckPointer(layers) == POINTER_INVALID || CheckPointer(inputVals) == POINTER_INVALID || layers.Total() <= 1)
return false;
//--- Pure-MQL5 inference: OCL-format neurons loaded host-only, computed in double precision. Separate
//--- path because the branches below assume either a device backend or the plain CNeuronBase model.
if(m_cpuInference)
return feedForwardCPU(inputVals);
//---
CLayer *previous = NULL;
CLayer *current = layers.At(0);
int total = MathMin(current.Total(), inputVals.Total());
CNeuronBase *neuron = NULL;
bool gpuActive = (CheckPointer(opencl) != POINTER_INVALID || CheckPointer(directml) != POINTER_INVALID);
if(!gpuActive)
{
for(int i = 0; i < total; i++)
{
neuron = current.At(i);
if(CheckPointer(neuron) == POINTER_INVALID)
return false;
neuron.setOutputVal(inputVals.At(i));
}
}
else
{
CNeuronBaseOCL *neuron_ocl = current.At(0);
int total_data = inputVals.Total();
bool written;
if(CheckPointer(opencl) != POINTER_INVALID)
{
//--- OpenCL device buffers are float32 (see AI\Network.cl) - unlike CBufferDouble's own
//--- BufferWrite(), this call bypasses that class entirely (it writes straight into the
//--- first layer's Output buffer by index), so the narrow-to-float has to happen here too.
float array[];
if(ArrayResize(array, total_data) < 0)
return false;
for(int d = 0; d < total_data; d++)
array[d] = (float)inputVals.At(d);
written = opencl.BufferWrite(neuron_ocl.getOutputIndex(), array, 0, 0, total_data);
}
else
{
double array[];
if(ArrayResize(array, total_data) < 0)
return false;
for(int d = 0; d < total_data; d++)
array[d] = inputVals.At(d);
written = directml.BufferWrite(neuron_ocl.getOutputIndex(), array, total_data);
}
if(!written)
return false;
}
//---
CObject *temp = NULL;
for(int l = 1; l < layers.Total(); l++)
{
previous = current;
current = layers.At(l);
if(CheckPointer(current) == POINTER_INVALID)
return false;
//---
if(gpuActive)
{
CNeuronBaseOCL *current_ocl = current.At(0);
if(!current_ocl.feedForward(previous.At(0)))
return false;
continue;
}
//---
total = current.Total();
if(current.At(0).Type() == defNeuron)
total--;
//---
for(int n = 0; n < total; n++)
{
neuron = current.At(n);
if(CheckPointer(neuron) == POINTER_INVALID)
return false;
if(previous.At(0).Type() == defNeuron)
{
temp = previous;
if(!neuron.feedForward(temp))
return false;
continue;
}
if(neuron.Type() == defNeuron)
{
if(n == 0)
{
CLayer *temp_l = new CLayer(total);
if(CheckPointer(temp_l) == POINTER_INVALID)
return false;
CNeuronPool *Pool = NULL;
for(int p = 0; p < previous.Total(); p++)
{
Pool = previous.At(p);
if(CheckPointer(Pool) == POINTER_INVALID)
return false;
temp_l.AddArray(Pool.getOutputLayer());
}
temp = temp_l;
}
if(!neuron.feedForward(temp))
return false;
if(n == total - 1)
{
CLayer *temp_l = temp;
temp_l.FreeMode(false);
temp_l.Shutdown();
delete temp_l;
}
continue;
}
temp = previous.At(n);
if(CheckPointer(temp) == POINTER_INVALID)
return false;
if(!neuron.feedForward(temp))
return false;
}
}
//---
return true;
}
//+------------------------------------------------------------------+
//| Pure-MQL5 forward pass over an OCL-format network loaded host-only|
//| (no OpenCL/DirectML/DLL). Layer 0 is fed from inputVals; each |
//| subsequent layer's OCL neuron computes via its virtual |
//| feedForwardCPU() (dense/conv/pool/LSTM). See SetCpuInference(). |
//+------------------------------------------------------------------+
bool CNet::feedForwardCPU(CArrayDouble *inputVals)
{
CLayer *current = layers.At(0);
if(CheckPointer(current) == POINTER_INVALID)
return false;
CNeuronBaseOCL *in0 = current.At(0);
if(CheckPointer(in0) == POINTER_INVALID || !in0.SetInputsCPU(inputVals))
return false;
for(int l = 1; l < layers.Total(); l++)
{
CLayer *previous = current;
current = layers.At(l);
if(CheckPointer(current) == POINTER_INVALID)
return false;
CNeuronBaseOCL *cur = current.At(0);
CNeuronBaseOCL *prev = previous.At(0);
if(CheckPointer(cur) == POINTER_INVALID || CheckPointer(prev) == POINTER_INVALID)
return false;
if(!cur.feedForwardCPU(prev))
return false;
}
return true;
}
//+------------------------------------------------------------------+
//| |
//+------------------------------------------------------------------+
void CNet::backProp(CArrayDouble *targetVals, double sampleWeight)
{
if(CheckPointer(targetVals) == POINTER_INVALID || CheckPointer(layers) == POINTER_INVALID)
return;
if(CheckPointer(opencl) != POINTER_INVALID || CheckPointer(directml) != POINTER_INVALID)
{
backPropOCL(targetVals, sampleWeight);
return;
}
//---
CLayer *outputLayer = layers.At(layers.Total() - 1);
if(CheckPointer(outputLayer) == POINTER_INVALID)
return;
//---
//--- Defensive: this is the pure-MQL5 CPU backward pass and it walks CNeuron/CNeuronBase objects
//--- with SCALAR getOutputVal()/getGradient()/setGradient() accessors. CNeuronBaseOCL & friends do
//--- NOT derive from CNeuronBase and expose only ARRAY accessors over a device buffer, so they can
//--- never be walked here. That is normally impossible to reach: the early return above hands any
//--- backend-backed net to backPropOCL(), and CNet::Create() only ever constructs OCL neurons when
//--- a backend exists. The one way to hold OCL neurons with no backend is pure-MQL5 inference mode
//--- (SetCpuInference -> CLayer::CreateElementScaled's host-only branch), which is inference-only
//--- and never backprops (OnlineLearnStep() guards on Net.CpuInference()). Bail out loudly rather
//--- than mis-cast if that ever changes.
//--- 2026-07-28: a set of inline "handle OCL neuron types" branches was added throughout this
//--- function to cover that impossible case. They could not compile - they called the scalar
//--- accessors on CNeuronBaseOCL - and were removed; this guard replaces them.
CObject *probe = outputLayer.At(0);
if(CheckPointer(probe) != POINTER_INVALID)
{
int t0 = probe.Type();
if(t0 == defNeuronBaseOCL || t0 == defNeuronConvOCL || t0 == defNeuronPoolOCL || t0 == defNeuronLSTMOCL ||
t0 == defNeuronBatchNormOCL)
{
Print(__FUNCTION__ + ": REFUSED - CPU backward pass reached a net built from OpenCL/DirectML neurons with no compute backend attached. Nothing was trained this step.");
return;
}
}
//---
double error = 0.0;
int total = outputLayer.Total() - 1;
//--- 3-output classification case: true softmax + categorical-cross-entropy gradient
//--- (dL/dz_i = softmax_i - target_i, the standard multi-class formula - see nnbook.txt section
//--- 1.4) instead of 3 independent per-neuron sigmoid deltas. Forward activation stays SIGMOID
//--- (bounded, avoids the historical logit-runaway collapse documented at
//--- BuildFreshTopology()'s desc.activation comment in ExpertSignalAIBase.mqh), but the BACKWARD
//--- delta is now computed from the softmax-normalized probability across all 3 outputs jointly,
//--- not each neuron's own raw sigmoid value in isolation. This is what actually ties Buy/Sell/
//--- Neutral together during training: raising one class's softmax probability now structurally
//--- lowers the other two's (via the shared normalizing sum), giving real competition instead of
//--- three independent binary regressions that can all drift toward "predict Neutral" together -
//--- root cause of the "overshoot to all-Neutral" convergence failure this replaces.
bool useSoftmaxGrad = (total == 3);
double smax[3];
if(useSoftmaxGrad)
{
//--- Logit adjustment added BEFORE the max-subtraction so the shift stays numerically safe.
double logit[3];
double maxLogit = -DBL_MAX;
for(int n = 0; n < 3; n++)
{
CNeuron *nrn = outputLayer.At(n);
logit[n] = CLASS_LOGIT_SCALE * nrn.getOutputVal() + (bLogitAdjust ? dLogitAdjust[n] : 0.0);
maxLogit = MathMax(maxLogit, logit[n]);
}
double sum = 0.0;
for(int n = 0; n < 3; n++)
{
smax[n] = exp(logit[n] - maxLogit);
sum += smax[n];
}
for(int n = 0; n < 3; n++)
smax[n] /= sum;
}
for(int n = 0; n < total && !IsStopped(); n++)
{
CNeuron *neuron = outputLayer.At(n);
double target = targetVals.At(n);
double clampedTarget = (target > 1 ? 1 : target < -1 ? -1 : target);
double delta = clampedTarget - neuron.getOutputVal();
error += delta * delta;
if(useSoftmaxGrad)
neuron.setGradient(clampedTarget - smax[n]);
else
neuron.calcOutputGradients(targetVals.At(n));
//--- inverse-class-frequency loss weighting (see ExpertSignalAIBase.mqh's Train() for how
//--- sampleWeight is derived) - scales the just-computed output gradient in place, before the
//--- hidden layers below read it via sumDOW(), so the whole backward chain sees the weighted
//--- signal without needing its own separate weighting logic.
if(sampleWeight != 1.0)
neuron.setGradient(neuron.getGradient() * sampleWeight);
}
error /= total;
error = sqrt(error);
recentAverageError += (error - recentAverageError) / recentAverageSmoothingFactor;
//---
CNeuronBase *neuron = NULL;
CObject *temp = NULL;
for(int layerNum = layers.Total() - 2; layerNum > 0; layerNum--)
{
CLayer *hiddenLayer = layers.At(layerNum);
CLayer *nextLayer = layers.At(layerNum + 1);
total = hiddenLayer.Total();
for(int n = 0; n < total && !IsStopped(); ++n)
{
neuron = hiddenLayer.At(n);
if(nextLayer.At(0).Type() == defNeuron)
{
temp = nextLayer;
neuron.calcHiddenGradients(temp);
continue;
}
if(neuron.Type() == defNeuron)
{
double g = 0;
for(int i = 0; i < nextLayer.Total(); i++)
{
temp = nextLayer.At(i);
neuron.calcHiddenGradients(temp);
g += neuron.getGradient();
}
neuron.setGradient(g);
continue;
}
temp = nextLayer.At(n);
neuron.calcHiddenGradients(temp);
}
}
//---
for(int layerNum = layers.Total() - 1; layerNum > 0; layerNum--)
{
CLayer *layer = layers.At(layerNum);
CLayer *prevLayer = layers.At(layerNum - 1);
total = layer.Total() - (layer.At(0).Type() == defNeuron ? 1 : 0);
int n_conv = 0;
for(int n = 0; n < total && !IsStopped(); n++)
{
neuron = layer.At(n);
if(CheckPointer(neuron) == POINTER_INVALID)
return;
if(neuron.Type() == defNeuronPool)
continue;
switch(prevLayer.At(0).Type())
{
case defNeuron:
temp = prevLayer;
neuron.updateInputWeights(temp);
break;
case defNeuronConv:
case defNeuronPool:
case defNeuronLSTM:
if(neuron.Type() == defNeuron)
{
for(n_conv = 0; n_conv < prevLayer.Total(); n_conv++)
{
temp = prevLayer.At(n_conv);
neuron.updateInputWeights(temp);
}
}
else
{
temp = prevLayer.At(n);
neuron.updateInputWeights(temp);
}
break;
default:
temp = NULL;
break;
}
}
}
}
//+------------------------------------------------------------------+
//| |
//+------------------------------------------------------------------+
void CNet::backPropOCL(CArrayDouble *targetVals, double sampleWeight)
{
if(CheckPointer(targetVals) == POINTER_INVALID || CheckPointer(layers) == POINTER_INVALID ||
(CheckPointer(opencl) == POINTER_INVALID && CheckPointer(directml) == POINTER_INVALID))
return;
CLayer *currentLayer = (CLayer*)layers.At(layers.Total() - 1);
if(CheckPointer(currentLayer) == POINTER_INVALID)
return;
//---
double error = 0.0;
int total = targetVals.Total();
double result[];
CNeuronBaseOCL *neuron = (CNeuronBaseOCL*)currentLayer.At(0);
if(neuron.getOutputVal(result) < total)
return;
for(int n = 0; n < total && !IsStopped(); n++)
{
double target = targetVals.At(n);
// Deliberately NOT special-cased on target==0 (an earlier version zeroed delta whenever
// target==0, so a one-hot classification target only ever counted the true class's own
// error - e.g. a Neutral-labeled bar's Buy/Sell neurons were invisible to this metric,
// which is what CNet::backProp()'s CPU-fallback path computes for every output
// unconditionally, and is what actually drives the dError<0.1 convergence gate in
// ExpertSignalAIBase::Train(). The real gradient (CPU_CalcOutputGradient in WarriorCPU.cpp
// / DirectML's equivalent) was never affected - only this diagnostic/convergence metric was.
double delta = (target > 1 ? 1 : target < -1 ? -1 : target) - result[n];
error += MathPow(delta, 2);
}
error /= total;
error = sqrt(error);
recentAverageError += (error - recentAverageError) / recentAverageSmoothingFactor;
if(!neuron.calcOutputGradients(targetVals))
return;
//--- 3-output classification case: overwrite the native per-neuron sigmoid delta with the true
//--- softmax + categorical-cross-entropy gradient (softmax_i - target_i), computed here in MQL5
//--- from the raw outputs already read back into result[] above - see the matching CNet::backProp()
//--- (CPU fallback) comment for the full rationale (ties Buy/Sell/Neutral together via the shared
//--- softmax normalizer instead of training 3 independent binary regressions). Backend DLLs/kernels
//--- (WarriorCPU.dll/WarriorDML.dll/Network.cl) still only ever compute the raw, unweighted
//--- per-neuron delta; this correction - like the sampleWeight scaling below - is applied entirely
//--- on the MQL5 side, so none of the 3 compute backends need to change.
if(total == 3)
{
//--- Logit adjustment (see SetLogitAdjustment): tau*log(prior_c) per class, added to the logit
//--- before the softmax. Backward pass ONLY - the forward pass and every inference path stay
//--- untouched, which is the whole point: the network learns to absorb the offset, so at
//--- inference its RAW argmax is already the balanced-error-optimal decision.
double logit[3];
double maxLogit = -DBL_MAX;
for(int n = 0; n < 3; n++)
{
logit[n] = CLASS_LOGIT_SCALE * result[n] + (bLogitAdjust ? dLogitAdjust[n] : 0.0);
maxLogit = MathMax(maxLogit, logit[n]);
}
double smax[3];
double sm = 0.0;
for(int n = 0; n < 3; n++)
{
smax[n] = exp(logit[n] - maxLogit);
sm += smax[n];
}
double gradOverwrite[3];
for(int n = 0; n < 3; n++)
{
smax[n] /= sm;
double target = targetVals.At(n);
double clampedTarget = (target > 1 ? 1 : target < -1 ? -1 : target);
gradOverwrite[n] = clampedTarget - smax[n];
}
neuron.setGradient(gradOverwrite);
}
//--- inverse-class-frequency loss weighting (see ExpertSignalAIBase.mqh's Train() for how
//--- sampleWeight is derived). CalcOutputGradient() above only computes the raw, unweighted delta
//--- (WarriorCPU.dll/WarriorDML.dll/Network.cl have no notion of per-sample weighting), so the
//--- gradient buffer is read back, scaled here in MQL5, and pushed back before the hidden layers
//--- below read it via CalcHiddenGradient/sumDOW - avoids touching any of the 3 compute backends.
if(sampleWeight != 1.0)
{
double gradVals[];
int gradCount = neuron.getGradient(gradVals);
if(gradCount > 0)
{
for(int g = 0; g < gradCount; g++)
gradVals[g] *= sampleWeight;
neuron.setGradient(gradVals);
}
}
//--- Calc Hidden Gradients
CObject *temp = NULL;
total = layers.Total();
for(int layerNum = total - 2; layerNum > 0; layerNum--)
{
CLayer *nextLayer = currentLayer;
currentLayer = layers.At(layerNum);
neuron = currentLayer.At(0);
neuron.calcHiddenGradients(nextLayer.At(0));
}
//--- Layer-1 LSTM special case. The loop above deliberately stops at layerNum > 0 because the INPUT
//--- layer needs no gradient of its own - true for every layer type whose updateInputWeights() derives
//--- its own weight deltas from (own gradient x previous output) inside the kernel: dense
//--- (UpdateWeightsMomentum/Adam) and conv (UpdateWeightsConvMomentum/Adam) both do.
//--- CNeuronLSTMOCL is the ONE exception: LSTM_UpdateWeightsMomentum/Adam do not derive anything, they
//--- only CONSUME the WeightsGradient buffer, and that buffer is filled purely as a SIDE EFFECT of
//--- CNeuronLSTMOCL::calcInputGradients() - which is invoked by the layer BELOW, via its
//--- calcHiddenGradients(target=thisLSTM) dispatch. So an LSTM sitting at layer index 1 (the LSTM_2L
//--- preset: input -> LSTM -> dense -> dense -> output) never gets calcInputGradients() called at all:
//--- WeightsGradient stays at its BufferInit(total, 0) zeros forever, Adam's mt/vt therefore stay 0 and
//--- every weight delta is exactly 0. The LSTM silently never trains - it stays a frozen random
//--- recurrent projection while only the dense taper above it learns, which shows up as ~0% Buy/Sell
//--- recall and a raw-output range that barely moves off its initialisation.
//--- HYBRID_2L (input -> conv -> pool -> LSTM -> dense -> dense -> output) is unaffected: its LSTM is at
//--- index 3, so the pool layer below it makes the call in the normal course of the loop.
//--- Done here rather than by relaxing the loop bound so the loop's currentLayer/nextLayer bookkeeping
//--- is untouched, and so no other topology pays for an extra kernel dispatch it does not need.
if(total >= 3)
{
CLayer *firstHidden = layers.At(1);
CLayer *inputLayer = layers.At(0);
if(CheckPointer(firstHidden) != POINTER_INVALID && CheckPointer(inputLayer) != POINTER_INVALID &&
CheckPointer(firstHidden.At(0)) != POINTER_INVALID && CheckPointer(inputLayer.At(0)) != POINTER_INVALID &&
firstHidden.At(0).Type() == defNeuronLSTMOCL)
{
CNeuronLSTMOCL *lstm = firstHidden.At(0);
CNeuronBaseOCL *inputNeuron = inputLayer.At(0);
//--- Safe to run now and only now: the loop above has already filled this LSTM's own Gradient
//--- (it processed layerNum == 1), which is exactly what LSTMGateGradient reads.
if(!lstm.calcInputGradients(inputNeuron))
printf("%s: LSTM at layer 1 failed to compute its weight gradients - it will not train this step", __FUNCTION__);
}
}
//---
CLayer *prevLayer = layers.At(total - 1);
for(int layerNum = total - 1; layerNum > 0; layerNum--)
{
currentLayer = prevLayer;
prevLayer = layers.At(layerNum - 1);
neuron = currentLayer.At(0);
neuron.updateInputWeights(prevLayer.At(0));
}
}
//+------------------------------------------------------------------+
//| |
//+------------------------------------------------------------------+
void CNet::getResults(CArrayDouble *&resultVals)
{
if(CheckPointer(resultVals) == POINTER_INVALID)
{
resultVals = new CArrayDouble();
if(CheckPointer(resultVals) == POINTER_INVALID)
return;
}
//---
resultVals.Clear();
if(CheckPointer(layers) == POINTER_INVALID || layers.Total() <= 0)
return;
//---
CLayer *output = layers.At(layers.Total() - 1);
if(CheckPointer(output) == POINTER_INVALID)
return;
//---
if(CheckPointer(opencl) != POINTER_INVALID || CheckPointer(directml) != POINTER_INVALID)
{
switch(output.At(0).Type())
{
case defNeuronBaseOCL:
case defNeuronConvOCL:
case defNeuronPoolOCL:
case defNeuronLSTMOCL:
{
CNeuronBaseOCL *temp = output.At(0);
temp.getOutputVal(resultVals);
return;
}
}
}
//--- pure-MQL5 inference: OCL output neuron computed host-side, read without a device BufferRead.
if(m_cpuInference)
{
switch(output.At(0).Type())
{
case defNeuronBaseOCL:
case defNeuronConvOCL:
case defNeuronPoolOCL:
case defNeuronLSTMOCL:
{
CNeuronBaseOCL *temp = output.At(0);
temp.GetOutputsCPU(resultVals);
return;
}
}
}
CNeuronBase *neuron = NULL;
CLayer *temp = NULL;
int total = output.Total();
if(output.At(0).Type() == defNeuron)
total--;
//---
for(int i = 0; i < total; i++)
{
CObject *obj = output.At(i);
if(CheckPointer(obj) == POINTER_INVALID)
continue;
if(obj.Type() == defNeuron)
{
neuron = (CNeuronBase*)obj;
resultVals.Add(neuron.getOutputVal());
continue;
}
if(obj.Type() == defNeuronPool)
{
CNeuronPool *n = (CNeuronPool*)obj;
temp = n.getOutputLayer();
for(int ii = 0; ii < temp.Total(); ii++)
{
CObject *poolObj = temp.At(ii);
if(CheckPointer(poolObj) == POINTER_INVALID)
continue;
CNeuronBase *poolNeuron = (CNeuronBase*)poolObj;
resultVals.Add(poolNeuron.getOutputVal());
}
}
}
}
//+------------------------------------------------------------------+
//| |
//+------------------------------------------------------------------+
//+------------------------------------------------------------------+
//| Install the per-class logit offsets - see the declaration. |
//| Expects tau*log(prior_c) already computed by the caller, which is |
//| the only place that knows the training set's class distribution. |
//+------------------------------------------------------------------+
void CNet::SetLogitAdjustment(const double &offsets[])
{
if(ArraySize(offsets) < 3)
{
//--- Refuse rather than half-apply: a partially filled offset vector would silently bias two
//--- classes against a third, which is far worse than running unadjusted.
bLogitAdjust = false;
return;
}
for(int i = 0; i < 3; i++)
{
//--- Guard against a non-finite offset reaching the softmax (a zero prior would give -inf and
//--- turn every gradient into NaN). A class with no examples at all simply gets no push.
double v = offsets[i];
if(!MathIsValidNumber(v))
v = 0.0;
dLogitAdjust[i] = v;
}
bLogitAdjust = true;
}
#endif