//+------------------------------------------------------------------+ //| 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