Warrior_EA/AI/Network.cl

1134 lines
60 KiB
Common Lisp
Raw Permalink Normal View History

// fp32 throughout - this project targets consumer GPUs (e.g. AMD Polaris/RX 580) where double
// precision runs at a small fraction of float throughput. No kernel below needs fp64 range or
// precision: weight/delta clamps stay within +-100, gradients/activations are float32-safe, and
// the Adam epsilon guard (vt > 0 ? vt : l*10) isn't a tiny-epsilon comparison sensitive to
// fp32 vs fp64 underflow. Removing cl_khr_fp64 means any accidental double slipping back in fails
// kernel compilation loudly instead of silently running at 1/16th speed.
// Tightened from 1.0e6 - see AI\Network.mqh's matching MAX_WEIGHT comment for the full rationale
// (that ceiling was so loose it never engaged before training had already gone unstable). 100.0
// matches the equivalent clamp in Dmitriy Gizlyk's reference NeuroNet.cl engine.
#define MAX_WEIGHT 100.0f
// Floor on the magnitude of a saturated tanh/sigmoid unit's activation derivative - see AI\Network.mqh's
// matching MIN_ACTIVATION_DERIVATIVE comment for the full rationale (a unit pinned near its activation
// extremes must still receive some corrective gradient, however small, rather than exactly zero).
// 2026-07-27: Increased from 1.0e-4f to 1.0e-3f to strengthen the escape signal through saturated hidden
// neurons. On the fp32 OpenCL backend, a saturated sigmoid output layer (the "neutral collapse from era 0"
// bug) propagated only this floor value backward through every hidden layer, which at 1e-4 was too weak
// to ever pull weights out of saturation. The output-layer saturation root cause is now fixed (NONE/logit
// activation instead of SIGMOID see ExpertSignalAIBase.mqh's BuildFreshTopology), but this increase
// provides a 10× stronger safety net against any remaining fp32-specific saturation in hidden layers.
#define MIN_ACTIVATION_DERIVATIVE 1.0e-3f
// Decoupled (AdamW-style) weight decay applied inside every Adam kernel below - see WarriorCPU.cpp's
// matching WEIGHT_DECAY comment for the full rationale (unbounded slow weight growth across hundreds
// of oversampled training eras causing periodic collapse-then-recover cycles). 0.01 is the standard
// AdamW default.
#define WEIGHT_DECAY 0.001f
// Per-step update clip - see WarriorCPU.cpp's matching MAX_WEIGHT_DELTA comment for the full
// rationale (weight decay alone didn't stop the collapse cycles - they turned out to be sudden Adam
// overshoot events, most likely from 5x back-to-back oversampling replay building artificially
// correlated momentum). Applied to the raw delta BEFORE it's added to the weight, unlike MAX_WEIGHT.
#define MAX_WEIGHT_DELTA 0.1f
2025-05-30 16:35:54 +02:00
//+------------------------------------------------------------------+
//| |
//+------------------------------------------------------------------+
__kernel void FeedForward(__global float *matrix_w,
__global float *matrix_i,
__global float *matrix_o,
2025-05-30 16:35:54 +02:00
int inputs, int activation)
{
int i = get_global_id(0);
float sum = 0.0f;
float4 inp, weight;
2025-05-30 16:35:54 +02:00
int shift = (inputs + 1) * i;
for(int k = 0; k <= inputs; k = k + 4)
{
switch(inputs - k)
{
case 0:
inp = (float4)(1, 0, 0, 0);
weight = (float4)(matrix_w[shift + k], 0, 0, 0);
2025-05-30 16:35:54 +02:00
break;
case 1:
inp = (float4)(matrix_i[k], 1, 0, 0);
weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], 0, 0);
2025-05-30 16:35:54 +02:00
break;
case 2:
inp = (float4)(matrix_i[k], matrix_i[k + 1], 1, 0);
weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], matrix_w[shift + k + 2], 0);
2025-05-30 16:35:54 +02:00
break;
case 3:
inp = (float4)(matrix_i[k], matrix_i[k + 1], matrix_i[k + 2], 1);
weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], matrix_w[shift + k + 2], matrix_w[shift + k + 3]);
2025-05-30 16:35:54 +02:00
break;
default:
inp = (float4)(matrix_i[k], matrix_i[k + 1], matrix_i[k + 2], matrix_i[k + 3]);
weight = (float4)(matrix_w[shift + k], matrix_w[shift + k + 1], matrix_w[shift + k + 2], matrix_w[shift + k + 3]);
2025-05-30 16:35:54 +02:00
break;
}
sum += dot(inp, weight);
}
switch(activation)
{
case 0:
sum = tanh(sum);
break;
case 1:
sum = 1 / (1 + exp(-clamp(sum, -50.0f, 50.0f)));
2025-05-30 16:35:54 +02:00
break;
case 2: // PReLU, param=0.01 - matches FeedForwardConv's case 2 below
if(sum < 0)
sum *= 0.01f;
break;
2025-05-30 16:35:54 +02:00
}
matrix_o[i] = sum;
barrier(CLK_GLOBAL_MEM_FENCE);
}
//+------------------------------------------------------------------+
//| |
//+------------------------------------------------------------------+
__kernel void CaclOutputGradient(__global float *matrix_t,
__global float *matrix_o,
__global float *matrix_ig,
2025-05-30 16:35:54 +02:00
int activation)
{
int i = get_global_id(0);
float temp = 0;
float out = matrix_o[i];
2025-05-30 16:35:54 +02:00
switch(activation)
{
case 0:
// Deliberately NOT multiplied by the tanh derivative (1-out^2): that
// factor vanishes as out approaches +-1, which is exactly where a
// TANH output neuron needs to converge for a +-1 target (e.g. the
// buy/sell extremes of a single-neuron regression head), stalling
// training right when it matters most. Using the raw (target-out)
// error here is the same fix as pairing sigmoid with cross-entropy -
// a bounded activation whose output error isn't re-damped by its
// own saturating derivative.
temp = clamp(matrix_t[i], -1.0f, 1.0f) - out;
2025-05-30 16:35:54 +02:00
break;
case 1:
// Also deliberately NOT multiplied by the sigmoid derivative out*(1-out) - same
// reasoning as case 0 above. This is the classification output layer (3 neurons,
// one-hot 0/1 targets - see BuildFreshTopology()); (target-out) unscaled is exactly
// the sigmoid+binary-cross-entropy gradient (the loss derivative and the sigmoid
// derivative algebraically cancel), whereas re-multiplying by out*(1-out) here is
// the MSE-with-sigmoid formula, which damps toward zero as out approaches 0 or 1 -
// exactly where a 0/1-target neuron needs to converge. With this damping (plus the
// 0.00000001 underflow guard at the saturated extremes), the classification output
// layer's effective learning rate was throttled to a small fraction of the
// regression path's, observed in practice as all 3 output neurons converging to
// (and never escaping) an identical value - softmax of three equal logits is always
// exactly 1/3 each, with ties resolved to index 0 ("Buy" wins by default).
temp = clamp(matrix_t[i], 0.0f, 1.0f) - out;
2025-05-30 16:35:54 +02:00
break;
case 2:
// PReLU, param=0.01 - unlike tanh/sigmoid, PReLU is unbounded and never saturates,
// so none of the clamp-to-range/derivative-skip workarounds above are needed here:
// plain (target-out) scaled by the derivative at this output is exact.
temp = (matrix_t[i] - out) * (out >= 0 ? 1.0f : 0.01f);
break;
default:
// NONE (raw logits - see the 3-class softmax classification output layer in
// BuildFreshTopology()/NativeActivationCode()'s NONE case, which maps here): identity
// activation, derivative 1 everywhere, so the plain (target-out) error passes through
// unscaled - same formula CNeuron::calcOutputGradients() already uses unconditionally
// on the un-accelerated CPU fallback path. Leaving this case unhandled left `temp` at
// its 0.0 initializer, i.e. every softmax-classification output neuron got a
// permanently zero gradient on this backend - which, since calcHiddenGradients()
// propagates backward FROM the output layer's gradient, silently froze the entire
// network (not just the output layer) at its random initial weights whenever an
// OpenCL-accelerated tier was active.
temp = matrix_t[i] - out;
break;
2025-05-30 16:35:54 +02:00
}
matrix_ig[i] = temp;
}
//+------------------------------------------------------------------+
//| |
//+------------------------------------------------------------------+
__kernel void CaclHiddenGradient(__global float *matrix_w,
__global float *matrix_g,
__global float *matrix_o,
__global float *matrix_ig,
2025-05-30 16:35:54 +02:00
int outputs, int activation)
{
int i = get_global_id(0);
fix: dense backprop read the weight matrix transposed - on every backend CaclHiddenGradient computed this layer's gradient as matrix_w[(outputs+1)*i + k] against a buffer whose actual layout (one row per NEXT-layer neuron, stride inputs+1) makes the correct read matrix_w[k*(inputs+1) + i]: the transpose for square layers, and for the non-square boundaries this EA actually builds (tapered stacks, the 3-neuron head) a mis-strided walk that ran past the buffer end - garbage on OpenCL, zeroed reads on the CPU DLL, so the tiers did not even agree with each other. Every gradient crossing a dense boundary on its way down - the entire learning signal reaching the BN/conv/LSTM front ends - passed through a fixed wrong matrix: feedback-alignment dynamics, not backprop, which is why nets still "learned something" and this survived. The book reference (NeuroNet_DNG) fixed this in a later article version; our kernel descended from the earlier one. Confounds every model-based negative verdict to date. Also in this commit, same root cause family: - per-sample UpdateWeightsAdam (OpenCL): input for slot group j was read at matrix_i[j] instead of matrix_i[j*4] (corrupted outer product past group 0), and dispatch dim 1 sized on ceil(inputs/4) left the bias column unreachable whenever inputs%4==0 - dense biases never trained on OpenCL. Rewritten as a lane-guarded scalar loop keeping our Adam conventions (sqrt-stored v, decoupled decay, both clamps, no sign gate). The batched accum path never had either bug; this kernel is what SetBatchSize(1) runs - including online continual learning on client machines, where OpenCL is the only tier. - conv backward passed raw (int)Activation() where the kernels expect NativeActivationCode(): NONE took the tanh branch (clamping a BN layer's unbounded z-scores), TANH took sigmoid, PRELU took none. Dormant only because the conv sits at layer 1 today. - hidden-gradient dispatch over Neurons()+1 dropped to Neurons(): biases get no backprop gradient and the extra work-item only ever read past matrix_o. All three backends (Network.cl, WarriorCPU.cpp, WarriorDML.cpp HLSL) changed in lockstep; DML gained an `inputs` constant to derive the row stride. New dense_backprop_check.cpp proves the CPU kernel is central-finite-difference consistent with the real forward kernel on 8x8, 64x3, 33x64, 5x3 (max diff 3e-9) and that all three activation branches match transcription. All 16 checks pass. Offline math check only - the in-situ proof remains the per-layer dW/W report on a real era. FORCES FULL RETRAIN. Both DLLs rebuilt and redeployed to MQL5\Libraries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:06:09 -04:00
// This layer's neuron count. Dispatch is exactly Neurons() (no +1: biases receive no backprop
// gradient, and the old +1 work-item only ever produced an out-of-bounds read of matrix_o).
int inputs = get_global_size(0);
float sum = 0;
float out = matrix_o[i];
fix: dense backprop read the weight matrix transposed - on every backend CaclHiddenGradient computed this layer's gradient as matrix_w[(outputs+1)*i + k] against a buffer whose actual layout (one row per NEXT-layer neuron, stride inputs+1) makes the correct read matrix_w[k*(inputs+1) + i]: the transpose for square layers, and for the non-square boundaries this EA actually builds (tapered stacks, the 3-neuron head) a mis-strided walk that ran past the buffer end - garbage on OpenCL, zeroed reads on the CPU DLL, so the tiers did not even agree with each other. Every gradient crossing a dense boundary on its way down - the entire learning signal reaching the BN/conv/LSTM front ends - passed through a fixed wrong matrix: feedback-alignment dynamics, not backprop, which is why nets still "learned something" and this survived. The book reference (NeuroNet_DNG) fixed this in a later article version; our kernel descended from the earlier one. Confounds every model-based negative verdict to date. Also in this commit, same root cause family: - per-sample UpdateWeightsAdam (OpenCL): input for slot group j was read at matrix_i[j] instead of matrix_i[j*4] (corrupted outer product past group 0), and dispatch dim 1 sized on ceil(inputs/4) left the bias column unreachable whenever inputs%4==0 - dense biases never trained on OpenCL. Rewritten as a lane-guarded scalar loop keeping our Adam conventions (sqrt-stored v, decoupled decay, both clamps, no sign gate). The batched accum path never had either bug; this kernel is what SetBatchSize(1) runs - including online continual learning on client machines, where OpenCL is the only tier. - conv backward passed raw (int)Activation() where the kernels expect NativeActivationCode(): NONE took the tanh branch (clamping a BN layer's unbounded z-scores), TANH took sigmoid, PRELU took none. Dormant only because the conv sits at layer 1 today. - hidden-gradient dispatch over Neurons()+1 dropped to Neurons(): biases get no backprop gradient and the extra work-item only ever read past matrix_o. All three backends (Network.cl, WarriorCPU.cpp, WarriorDML.cpp HLSL) changed in lockstep; DML gained an `inputs` constant to derive the row stride. New dense_backprop_check.cpp proves the CPU kernel is central-finite-difference consistent with the real forward kernel on 8x8, 64x3, 33x64, 5x3 (max diff 3e-9) and that all three activation branches match transcription. All 16 checks pass. Offline math check only - the in-situ proof remains the per-layer dW/W report on a real era. FORCES FULL RETRAIN. Both DLLs rebuilt and redeployed to MQL5\Libraries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:06:09 -04:00
// matrix_w is THIS layer's outgoing weight matrix, laid out exactly as FeedForward consumes it:
// one row per NEXT-layer neuron k, row stride (inputs + 1), so the weight FROM this neuron i
// INTO next-layer neuron k is matrix_w[k * (inputs + 1) + i] - a column read, dL/dout_i =
// sum_k g[k] * W[k][i]. Until 2026-08-11 this kernel read matrix_w[(outputs + 1) * i + k]: the
// TRANSPOSE for square layers and, for the non-square boundaries this EA actually builds
// (tapered stacks, the 3-neuron head), a mis-strided walk that ran past the buffer's end. Every
// gradient crossing a dense boundary on its way down - the entire learning signal reaching the
// BN/conv/LSTM front ends - passed through that fixed wrong matrix, i.e. feedback-alignment
// dynamics rather than backprop (which is why nets still "learned something" and it survived).
// The book reference (references\...\NeuroNet_DNG\NeuroNet.cl CalcHiddenGradient) has the
// corrected form; this repo's kernel descended from the earlier article version. Mirrored in
// DirectML\WarriorCPU.cpp (CPU_CalcHiddenGradient) and WarriorDML.cpp (kHlslHiddenGradient) -
// the three must stay in lockstep.
for(int k = 0; k < outputs; k++)
sum += matrix_g[k] * matrix_w[k * (inputs + 1) + i];
2025-05-30 16:35:54 +02:00
switch(activation)
{
case 0:
sum = clamp(sum + out, -1.0f, 1.0f) - out;
sum = sum * fmax(MIN_ACTIVATION_DERIVATIVE, 1 - pow(out, 2.0f));
2025-05-30 16:35:54 +02:00
break;
case 1:
sum = clamp(sum + out, 0.0f, 1.0f) - out;
sum = sum * fmax(MIN_ACTIVATION_DERIVATIVE, out * (1 - out));
2025-05-30 16:35:54 +02:00
break;
case 2:
// PReLU, param=0.01 - unbounded, non-saturating, so the plain chain rule applies
// directly with no clamp/implied-target reformulation needed (contrast with the
// tanh/sigmoid cases above, which exist specifically to dodge saturation).
sum = sum * (out >= 0 ? 1.0f : 0.01f);
break;
2025-05-30 16:35:54 +02:00
}
matrix_ig[i] = sum;
}
//+------------------------------------------------------------------+
//| |
//+------------------------------------------------------------------+
__kernel void UpdateWeightsMomentum(__global float *matrix_w,
__global float *matrix_g,
__global float *matrix_i,
__global float *matrix_dw,
int inputs, float learning_rates, float momentum, int optimizer)
2025-05-30 16:35:54 +02:00
{
int i = get_global_id(0);
int j = get_global_id(1);
int wi = i * (inputs + 1) + j;
// `optimizer` is retained (unused) only to keep this kernel's argument list binary-compatible with
// the already-deployed WarriorDML.dll/WarriorCPU.dll exports. It used to select an index-parity sign
// flip on the gradient ("DFA"), which put half of every weight tensor into permanent gradient ASCENT
// - see ENUM_OPTIMIZATION's comment in AI\Network.mqh. Always plain descent now; callers pass 0.
float delta = learning_rates * (matrix_g[i] * (j < inputs ? matrix_i[j] : 1)) + momentum * matrix_dw[wi];
2025-05-30 16:35:54 +02:00
matrix_dw[wi] = delta;
// Unlike UpdateWeightsConvMomentum/UpdateWeightsConvAdam/LSTM_UpdateWeightsAdam below, this dense
// kernel (and UpdateWeightsAdam right after it) had never gotten the MAX_WEIGHT clamp - a gradient
// spike could drive a weight to +-Infinity with nothing to stop it, and the next Adam step turns
// Infinity into NaN (Inf/Inf), which then propagates through every FeedForward sum touching that
// weight and never recovers. Dense layers are exactly where the classification output head lives,
// so this is the most consequential of the three missing clamps, not the least.
matrix_w[wi] = clamp(matrix_w[wi] + delta, -MAX_WEIGHT, MAX_WEIGHT);
2025-05-30 16:35:54 +02:00
};
//+------------------------------------------------------------------+
//| |
//+------------------------------------------------------------------+
__kernel void UpdateWeightsAdam(__global float *matrix_w,
__global const float *matrix_g,
__global const float *matrix_i,
__global float *matrix_m,
__global float *matrix_v,
const int inputs, const float l, const float b1, const float b2)
2025-05-30 16:35:54 +02:00
{
const int i = get_global_id(0);
const int j = get_global_id(1);
fix: dense backprop read the weight matrix transposed - on every backend CaclHiddenGradient computed this layer's gradient as matrix_w[(outputs+1)*i + k] against a buffer whose actual layout (one row per NEXT-layer neuron, stride inputs+1) makes the correct read matrix_w[k*(inputs+1) + i]: the transpose for square layers, and for the non-square boundaries this EA actually builds (tapered stacks, the 3-neuron head) a mis-strided walk that ran past the buffer end - garbage on OpenCL, zeroed reads on the CPU DLL, so the tiers did not even agree with each other. Every gradient crossing a dense boundary on its way down - the entire learning signal reaching the BN/conv/LSTM front ends - passed through a fixed wrong matrix: feedback-alignment dynamics, not backprop, which is why nets still "learned something" and this survived. The book reference (NeuroNet_DNG) fixed this in a later article version; our kernel descended from the earlier one. Confounds every model-based negative verdict to date. Also in this commit, same root cause family: - per-sample UpdateWeightsAdam (OpenCL): input for slot group j was read at matrix_i[j] instead of matrix_i[j*4] (corrupted outer product past group 0), and dispatch dim 1 sized on ceil(inputs/4) left the bias column unreachable whenever inputs%4==0 - dense biases never trained on OpenCL. Rewritten as a lane-guarded scalar loop keeping our Adam conventions (sqrt-stored v, decoupled decay, both clamps, no sign gate). The batched accum path never had either bug; this kernel is what SetBatchSize(1) runs - including online continual learning on client machines, where OpenCL is the only tier. - conv backward passed raw (int)Activation() where the kernels expect NativeActivationCode(): NONE took the tanh branch (clamping a BN layer's unbounded z-scores), TANH took sigmoid, PRELU took none. Dormant only because the conv sits at layer 1 today. - hidden-gradient dispatch over Neurons()+1 dropped to Neurons(): biases get no backprop gradient and the extra work-item only ever read past matrix_o. All three backends (Network.cl, WarriorCPU.cpp, WarriorDML.cpp HLSL) changed in lockstep; DML gained an `inputs` constant to derive the row stride. New dense_backprop_check.cpp proves the CPU kernel is central-finite-difference consistent with the real forward kernel on 8x8, 64x3, 33x64, 5x3 (max diff 3e-9) and that all three activation branches match transcription. All 16 checks pass. Offline math check only - the in-situ proof remains the per-layer dW/W report on a real era. FORCES FULL RETRAIN. Both DLLs rebuilt and redeployed to MQL5\Libraries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:06:09 -04:00
// Each work-item owns up to 4 consecutive slots of weight row i. The row is (inputs + 1) wide -
// the last column is the bias, whose implicit input is 1 - and dispatch dim 1 is sized on
// ceil((inputs + 1) / 4), so the bias column is always reachable. Until 2026-08-11 this kernel
// had two defects the book reference had already fixed: the input for slot group j was read at
// matrix_i[j] instead of matrix_i[j * 4] (every group past the first paired its weights with the
// WRONG input - a corrupted outer product), and the tail switch tested (inputs - j * 4) with
// dim 1 sized on ceil(inputs / 4), which made the bias column unreachable whenever
// inputs % 4 == 0 (32/64-wide layers: dense biases simply never trained on OpenCL). The batched
// path (AccumulateWeightGrad + ApplyAccumAdam) never had either bug; this per-sample kernel is
// what SetBatchSize(1) paths run - including online continual learning on client machines,
// where OpenCL is the only tier. The per-lane guard keeps the tail group off the next row.
for(int n = 0; n < 4; n++)
2025-05-30 16:35:54 +02:00
{
fix: dense backprop read the weight matrix transposed - on every backend CaclHiddenGradient computed this layer's gradient as matrix_w[(outputs+1)*i + k] against a buffer whose actual layout (one row per NEXT-layer neuron, stride inputs+1) makes the correct read matrix_w[k*(inputs+1) + i]: the transpose for square layers, and for the non-square boundaries this EA actually builds (tapered stacks, the 3-neuron head) a mis-strided walk that ran past the buffer end - garbage on OpenCL, zeroed reads on the CPU DLL, so the tiers did not even agree with each other. Every gradient crossing a dense boundary on its way down - the entire learning signal reaching the BN/conv/LSTM front ends - passed through a fixed wrong matrix: feedback-alignment dynamics, not backprop, which is why nets still "learned something" and this survived. The book reference (NeuroNet_DNG) fixed this in a later article version; our kernel descended from the earlier one. Confounds every model-based negative verdict to date. Also in this commit, same root cause family: - per-sample UpdateWeightsAdam (OpenCL): input for slot group j was read at matrix_i[j] instead of matrix_i[j*4] (corrupted outer product past group 0), and dispatch dim 1 sized on ceil(inputs/4) left the bias column unreachable whenever inputs%4==0 - dense biases never trained on OpenCL. Rewritten as a lane-guarded scalar loop keeping our Adam conventions (sqrt-stored v, decoupled decay, both clamps, no sign gate). The batched accum path never had either bug; this kernel is what SetBatchSize(1) runs - including online continual learning on client machines, where OpenCL is the only tier. - conv backward passed raw (int)Activation() where the kernels expect NativeActivationCode(): NONE took the tanh branch (clamping a BN layer's unbounded z-scores), TANH took sigmoid, PRELU took none. Dormant only because the conv sits at layer 1 today. - hidden-gradient dispatch over Neurons()+1 dropped to Neurons(): biases get no backprop gradient and the extra work-item only ever read past matrix_o. All three backends (Network.cl, WarriorCPU.cpp, WarriorDML.cpp HLSL) changed in lockstep; DML gained an `inputs` constant to derive the row stride. New dense_backprop_check.cpp proves the CPU kernel is central-finite-difference consistent with the real forward kernel on 8x8, 64x3, 33x64, 5x3 (max diff 3e-9) and that all three activation branches match transcription. All 16 checks pass. Offline math check only - the in-situ proof remains the per-layer dW/W report on a real era. FORCES FULL RETRAIN. Both DLLs rebuilt and redeployed to MQL5\Libraries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:06:09 -04:00
int col = j * 4 + n;
if(col > inputs)
2025-05-30 16:35:54 +02:00
break;
fix: dense backprop read the weight matrix transposed - on every backend CaclHiddenGradient computed this layer's gradient as matrix_w[(outputs+1)*i + k] against a buffer whose actual layout (one row per NEXT-layer neuron, stride inputs+1) makes the correct read matrix_w[k*(inputs+1) + i]: the transpose for square layers, and for the non-square boundaries this EA actually builds (tapered stacks, the 3-neuron head) a mis-strided walk that ran past the buffer end - garbage on OpenCL, zeroed reads on the CPU DLL, so the tiers did not even agree with each other. Every gradient crossing a dense boundary on its way down - the entire learning signal reaching the BN/conv/LSTM front ends - passed through a fixed wrong matrix: feedback-alignment dynamics, not backprop, which is why nets still "learned something" and this survived. The book reference (NeuroNet_DNG) fixed this in a later article version; our kernel descended from the earlier one. Confounds every model-based negative verdict to date. Also in this commit, same root cause family: - per-sample UpdateWeightsAdam (OpenCL): input for slot group j was read at matrix_i[j] instead of matrix_i[j*4] (corrupted outer product past group 0), and dispatch dim 1 sized on ceil(inputs/4) left the bias column unreachable whenever inputs%4==0 - dense biases never trained on OpenCL. Rewritten as a lane-guarded scalar loop keeping our Adam conventions (sqrt-stored v, decoupled decay, both clamps, no sign gate). The batched accum path never had either bug; this kernel is what SetBatchSize(1) runs - including online continual learning on client machines, where OpenCL is the only tier. - conv backward passed raw (int)Activation() where the kernels expect NativeActivationCode(): NONE took the tanh branch (clamping a BN layer's unbounded z-scores), TANH took sigmoid, PRELU took none. Dormant only because the conv sits at layer 1 today. - hidden-gradient dispatch over Neurons()+1 dropped to Neurons(): biases get no backprop gradient and the extra work-item only ever read past matrix_o. All three backends (Network.cl, WarriorCPU.cpp, WarriorDML.cpp HLSL) changed in lockstep; DML gained an `inputs` constant to derive the row stride. New dense_backprop_check.cpp proves the CPU kernel is central-finite-difference consistent with the real forward kernel on 8x8, 64x3, 33x64, 5x3 (max diff 3e-9) and that all three activation branches match transcription. All 16 checks pass. Offline math check only - the in-situ proof remains the per-layer dW/W report on a real era. FORCES FULL RETRAIN. Both DLLs rebuilt and redeployed to MQL5\Libraries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:06:09 -04:00
int wi = i * (inputs + 1) + col;
float inp = (col < inputs ? matrix_i[col] : 1.0f);
float g = matrix_g[i] * inp;
float mt = b1 * matrix_m[wi] + (1 - b1) * g;
// v is STORED already square-rooted (a standard deviation) so it can be the denominator
// directly; it must therefore be squared back before re-entering the recursion. Feeding the
// stored sqrt in as if it were the variance - which every Adam kernel here did until
// 2026-08-09 - mixes a std dev with a variance, and the resulting recursion has a fixed
// point at v ~= b2 for ANY |g| below 1. The denominator then stops tracking the gradient
// scale, delta becomes proportional to |g|, and Adam silently degrades to plain SGD. That is
// not academic: it is why the conv/LSTM front end (which sits behind a batch-norm and so
// receives gradients divided by sqrt(var) ~ 500) froze at dW/W 0.00% while the dense stack
// trained. Measured with DirectML\batch_accum_check.cpp against the shipped kernel: 3285x
// less displacement at |g|=1e-5 than at |g|=1, where a scale-invariant optimizer gives the
// same distance for both. See NeuronBatchNorm.mqh, which got this right first.
float vt = sqrt(b2 * matrix_v[wi] * matrix_v[wi] + (1 - b2) * g * g);
float w = matrix_w[wi];
float delta = clamp(l * mt / (vt > 0 ? vt : l * 10) - l * WEIGHT_DECAY * w, -MAX_WEIGHT_DELTA, MAX_WEIGHT_DELTA);
// See UpdateWeightsMomentum's comment just above for why the matrix_w write is clamped to
// MAX_WEIGHT - this dense Adam kernel is the one that updates the classification output
// layer. No sign-agreement gate on the matrix_w write (removed 2026-07, all four backends in
// sync) - see AI\NeuronCPU.mqh's CNeuron::updateInputWeights comment for why: it rectified
// the one-hot softmax-CCE gradient stream into a permanent downward ratchet (the all-Neutral
// collapse).
matrix_w[wi] = clamp(w + delta, -MAX_WEIGHT, MAX_WEIGHT);
matrix_m[wi] = mt;
matrix_v[wi] = vt;
2025-05-30 16:35:54 +02:00
}
};
//+------------------------------------------------------------------+
//| Max-pooling layer (CNeuronPoolOCL) - no weights, just a sliding |
//| max over the previous layer's output. Ported from the NeuroNet_DNG|
//| reference's CNeuronProofOCL kernels (these two had no bug). |
//+------------------------------------------------------------------+
__kernel void FeedForwardProof(__global float *matrix_i,
__global float *matrix_o,
int inputs, int window, int step)
{
int i = get_global_id(0);
int pos = i * step;
// Mirrors the bounds guard in this kernel's pure-MQL5 host counterpart
// (CNeuronPoolOCL::feedForwardCPU in AI\NeuronOCLConvPool.mqh) - outputs is sized so pos shouldn't
// reach inputs in practice, but without this guard an out-of-bounds pos would read past matrix_i.
if(pos >= inputs)
return;
float result = matrix_i[pos];
for(int k = 1; k < window; k++)
{
int shift = k + pos;
if(shift >= inputs)
break;
result = max(result, matrix_i[shift]);
}
matrix_o[i] = result;
}
//+------------------------------------------------------------------+
//| |
//+------------------------------------------------------------------+
__kernel void CalcInputGradientProof(__global float *matrix_i,
__global float *matrix_g,
__global float *matrix_o,
__global float *matrix_ig,
int outputs, int window, int step)
{
int i = get_global_id(0);
float prev_gradient = 0.0f;
float value = matrix_i[i];
int start = i - window + step;
start = (start - start % step) / step;
int stop = (i - i % step) / step + 1;
for(int out = max(0, start); out < min(outputs, stop); out++)
if(value == matrix_o[out])
prev_gradient += matrix_g[out];
matrix_ig[i] = prev_gradient;
}
//+------------------------------------------------------------------+
//| Convolution layer (CNeuronConvOCL) - ported from the NeuroNet_DNG |
//| reference library and adapted to this project's buffer convention.|
//| window_in+1 weights (incl. bias) are shared across all sliding |
//| positions; window_out lets one position produce several output |
//| channels (filters) at once. |
//+------------------------------------------------------------------+
__kernel void FeedForwardConv(__global float *matrix_w,
__global float *matrix_i,
__global float *matrix_o,
int inputs, int step, int window_in, int window_out,
int activation)
{
int i = get_global_id(0);
int shift_out = window_out * i;
int shift_in = step * i;
for(int out = 0; out < window_out; out++)
{
int shift = (window_in + 1) * out;
int stop = (window_in <= (inputs - shift_in) ? window_in : (inputs - shift_in));
float sum = 0.0f;
for(int k = 0; k < stop; k++)
sum += matrix_i[shift_in + k] * matrix_w[shift + k];
sum += matrix_w[shift + window_in]; // bias
switch(activation)
{
case 0:
sum = tanh(sum);
break;
case 1:
sum = 1 / (1 + exp(-clamp(sum, -50.0f, 50.0f)));
break;
case 2: // PReLU, param=0.01
if(sum < 0)
sum *= 0.01f;
break;
}
matrix_o[out + shift_out] = sum;
}
}
//+------------------------------------------------------------------+
//| |
//+------------------------------------------------------------------+
__kernel void CalcHiddenGradientConv(__global float *matrix_w,
__global float *matrix_g,
__global float *matrix_o,
__global float *matrix_ig,
int outputs, int step, int window_in, int window_out,
int activation)
{
int i = get_global_id(0);
float out = matrix_o[i];
int start = i - window_in + step;
start = max((start - start % step) / step, 0);
int stop = (i - i % step) / step + 1;
if(stop > (outputs / window_out))
stop = outputs / window_out;
float sum = 0.0f;
for(int h = 0; h < window_out; h++)
{
for(int k = start; k < stop; k++)
{
int shift_w = (stop - k - 1) * step + i % step + h * (window_in + 1);
int shift_g = k * window_out + h;
if(shift_g >= outputs || shift_w >= (window_in + 1) * window_out)
break;
sum += matrix_g[shift_g] * matrix_w[shift_w];
}
}
switch(activation)
{
case 0:
sum = clamp(sum + out, -1.0f, 1.0f) - out;
sum = sum * fmax(MIN_ACTIVATION_DERIVATIVE, 1 - pow(out, 2.0f));
break;
case 1:
sum = clamp(sum + out, 0.0f, 1.0f) - out;
sum = sum * fmax(MIN_ACTIVATION_DERIVATIVE, out * (1 - out));
break;
case 2: // PReLU, param=0.01
if(out < 0)
sum *= 0.01f;
break;
}
matrix_ig[i] = sum;
}
//+------------------------------------------------------------------+
//| |
//+------------------------------------------------------------------+
__kernel void UpdateWeightsConvMomentum(__global float *matrix_w,
__global float *matrix_g,
__global float *matrix_i,
__global float *matrix_dw,
int inputs, float learning_rates, float momentum,
int window_in, int window_out, int step, int optimizer)
{
int i = get_global_id(0);
int shift = i % (window_in + 1);
int shift_out = (i - shift) / (window_in + 1);
int total = (inputs - window_in) % step;
total = (inputs - window_in - total) / step + (total > 0 ? 1 : 0);
float grad = 0.0f;
for(int t = 0; t < total; t++)
{
if(shift != window_in && (shift + t * step) >= inputs)
break;
grad += matrix_g[t * window_out + shift_out] * (shift == window_in ? 1 : matrix_i[shift + t * step]);
}
// `optimizer` retained unused - see UpdateWeightsMomentum's comment above.
float delta = learning_rates * grad + momentum * matrix_dw[i];
matrix_dw[i] = delta;
matrix_w[i] = clamp(matrix_w[i] + delta, -MAX_WEIGHT, MAX_WEIGHT);
};
//+------------------------------------------------------------------+
//| |
//+------------------------------------------------------------------+
__kernel void UpdateWeightsConvAdam(__global float *matrix_w,
__global const float *matrix_g,
__global const float *matrix_i,
__global float *matrix_m,
__global float *matrix_v,
const int inputs, const float l, const float b1, const float b2,
int window_in, int window_out, int step)
{
int i = get_global_id(0); // weight offset within a single output channel's (window_in+1) block
if(i > window_in)
return;
int total = (inputs - (window_in - step)) % step;
total = (inputs - (window_in - step) - total) / step + (total > 0 ? 1 : 0);
for(int out = 0; out < window_out; out++)
{
int shift_w = i + out * (window_in + 1);
float grad = 0.0f;
for(int t = 0; t < total; t++)
{
if(i != window_in && (i + t * step) >= inputs)
break;
grad += matrix_g[t * window_out + out] * (i == window_in ? 1 : matrix_i[i + t * step]);
}
float mt = b1 * matrix_m[shift_w] + (1 - b1) * grad;
fix: the Adam second moment was never Adam - all four tiers Root cause of the B=32 regression, and it predates F4 entirely. Every Adam kernel stored v already square-rooted and then fed that stored value back in as if it were the variance: v_new = sqrt(b2 * v_old + (1 - b2) * g^2) That recursion has a fixed point at v ~= b2 = 0.999 for ANY gradient below unit scale, so the denominator stops tracking the gradient and Adam degrades into plain SGD with lr = lt. Measured against the shipped WarriorCPU.dll (batch_accum_check.cpp, TestOptimizerScaleInvariance), 4000 steps of a constant gradient: 3285x less displacement at |g|=1e-5 than at |g|=1, where a scale-invariant optimizer gives the same distance for both. After the fix all six magnitudes read 1.199 and v tracks |g| exactly. It hit conv/LSTM specifically because they sit behind a batch-norm with running variance ~2.6e+05, so their gradients arrive divided by ~500 - deep in the degraded regime - while the dense stack near the loss stayed in the working one. In situ on SP500 H1: lstm1 dW/W 2.62/10.0/7.14% -> 0.024/0.022/ 0.003%, conv1 decaying to 0.000% by era 30. NeuronBatchNorm.mqh already squared v back for gamma/beta and its comment named the kernels as wrong, which is exactly why gamma/beta kept training while the stages behind froze. Persisted .nnw needs no migration - v keeps its std-dev meaning. Also, the two ways F4 exposed it, both mine: - No LR compensation for B fewer steps per era. sqrt(B) for adaptive methods (Krizhevsky 2014; Granziol et al. 2022), applied once in InitialEtaForOptimizer(). Linear scaling (Goyal et al. 2017) is for SGD. - Plateau patience denominated in eras, so raising B made the ladder 32x more impatient in its only unit. PAI converged at era 41 on ~49k updates where the same config had been finding new bests at era 1028. TrainPlateauPatienceEras() stretches it by the same sqrt(B). TRAIN_BATCH_SIZE 32 -> 8 so the patience stretch stays affordable (8 -> 23 eras per stage, not 8 -> 45). Both helpers are identities at B=1. Deploy gate: DEPLOY_MIN_SIDE_RECALL_PCT (10%) folded into tradeableOK. The perceptron reported Sell:0% recall in all 41 eras, cleared the floor on Buy alone at 36.6% vs 34% chance, deployed, and sprayed buy arrows. Folded into the ranking key rather than checked at deploy time so a one-sided era cannot become best-so-far in the first place. Deinit: the arrow purge now runs BEFORE ExtPanel.Destroy(), an unbounded CAppDialog teardown that sat ahead of it - the same ordering inversion the rule there exists to prevent. CONV was force-terminated 4.8 s into OnDeinit (vs ~1.1 s for the three that finished) having reached none of its cleanup, so its arrows stayed on the chart. Steps are now timed in the log. PurgeChart's verification rescan filtered on OBJ_ARROW, the same blind spot as the bulk delete, so "persisted 10 ... cleared 0" passed silently. It now walks every object type and reports the object counts when both are zero. Both build variants compile 0 errors / 0 warnings; both DLLs rebuilt. FORCES A RETRAIN (already forced by N1) and both DLLs must ship with the .ex5. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 14:02:35 -04:00
// v squared back before the recursion - see UpdateWeightsAdam's comment above for why.
float vt = sqrt(b2 * matrix_v[shift_w] * matrix_v[shift_w] + (1 - b2) * pow(grad, 2.0f));
float delta = clamp(l * mt / (vt > 0 ? vt : l * 10) - l * WEIGHT_DECAY * matrix_w[shift_w], -MAX_WEIGHT_DELTA, MAX_WEIGHT_DELTA);
// Sign-agreement gate removed - see UpdateWeightsAdam's comment above for why.
matrix_w[shift_w] = clamp(matrix_w[shift_w] + delta, -MAX_WEIGHT, MAX_WEIGHT);
matrix_m[shift_w] = mt;
matrix_v[shift_w] = vt;
}
};
//+------------------------------------------------------------------+
feat: mini-batch gradient accumulation (F4), front-end-aware capacity budget (F6), split Wyckoff categoricals (N1) Completes the 2026-08-09 training audit. FORCES A RETRAIN of every Wyckoff-enabled config (N1 re-keys the fingerprint), and BOTH DLLs must be redeployed alongside the .ex5 - they carry new exports. F4 - mini-batch accumulation, TRAIN_BATCH_SIZE=32. Training was pure online SGD (one weight update per bar), which is the mechanical source of the era-to-era whipsaw every downstream guard was built to cope with. The O(n^2) outer product is native - AccumulateWeightGrad / AccumulateWeightGradConv / AccumulateBufferInto in Network.cl, WarriorCPU and WarriorDML - while the optimizer step is host-side MQL5 shared by all tiers (ApplyAccumToBlock), so there is one Adam/SGD implementation instead of four that can drift. - the LSTM needs no outer-product kernel (WeightsGradient already holds the sample's full dW) but could NOT simply be left un-zeroed between samples: CPU_LSTMSeqBackward/DML_LSTMSeqBackward memset it on entry. Hence a separate accumulator plus an elementwise add. - batch-norm gamma/beta accumulate in host arrays, not new BatchOptions slots - BN_OPT_STRIDE is baked into every persisted .nnw. - scoped to pass 2; online learning keeps immediate updates. Every save / checkpoint / scoring boundary flushes, scaling by the real sample count. - degrades to per-sample updates (one log line) on a tier that cannot accumulate, so old devices and DLL-free builds are unaffected. - verified offline: DirectML/batch_accum_check.cpp drives the real exports against an independent reference; at B=1 the accumulator matches the shipped unbatched kernel's own gradient to 1.1e-16. Math only - the in-situ check remains the per-layer dW/W report on a real era. F6 - ComputeFirstLayerWidth budgeted against the RAW input width even where a conv/LSTM front end had already reduced it, so an LSTM's dense stack was charged for 1,280 inputs when it receives 64. Confirmed from the deployed .cfg files: CONV, LSTM and HYBRID were all pinned at the 16-unit floor. Now budgeted against the front-end output and capped at it (never fan out), with the derivation reordered so both stages settle first. N1 - EventCode/EventPhase/StructuralPhase are signed categoricals packing direction and Wyckoff stage into one scalar across a sign discontinuity. Split into direction + [0,1] magnitude, the same convention the base OHLC block uses. Information-preserving; 13 readings now occupy 16 inputs. Compiled clean (0 errors, 0 warnings); both DLLs rebuilt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 11:48:03 -04:00
//| MINI-BATCH GRADIENT ACCUMULATION (2026-08-09 audit, F4). |
//| |
//| Training here is otherwise pure online SGD - one weight update |
//| per sample - which makes the per-update gradient noise maximal |
//| and the end-of-era weight state a high-variance random draw. The |
//| fix is to sum each weight's gradient over TRAIN_BATCH_SIZE |
//| samples and take ONE optimizer step on the mean. |
//| |
//| Only the ACCUMULATION lives here. The optimizer step itself is |
//| elementwise O(weights), so - exactly like the batch-norm layer |
//| and the softmax+CCE output gradient before it - it is computed |
//| once per BATCH in MQL5 against the host mirrors of these buffers |
//| (CNeuronBaseOCL::ApplyAccumulatedGradients). That keeps ONE |
//| optimizer implementation for all four compute tiers instead of |
//| four that can drift apart, which this engine has been bitten by |
//| repeatedly. |
//| |
//| The per-weight gradient expressions below are copied verbatim |
//| from UpdateWeightsAdam / UpdateWeightsConvAdam above - they MUST |
//| stay identical, because batch size 1 has to reproduce the |
//| unbatched path exactly. |
//+------------------------------------------------------------------+
__kernel void AccumulateWeightGrad(__global float *matrix_acc,
__global const float *matrix_g,
__global const float *matrix_i,
const int inputs)
{
const int i = get_global_id(0); // destination neuron
const int j = get_global_id(1); // source slot, j == inputs is the bias
const int wi = i * (inputs + 1) + j;
// Deliberately scalar, not the float4 form UpdateWeightsAdam uses: this kernel replaces that
// dispatch on batched runs rather than adding to it, so the dispatch count is unchanged, and a
// plain expression is far easier to hold against the host-side apply it has to agree with.
matrix_acc[wi] += matrix_g[i] * (j < inputs ? matrix_i[j] : 1.0f);
};
//+------------------------------------------------------------------+
//| Convolution counterpart. Flat over (window_in+1)*window_out, i.e. |
//| UpdateWeightsConvMomentum's indexing, but with the POSITION COUNT |
//| taken from UpdateWeightsConvAdam. |
//| |
//| Those two disagree, and it is worth being explicit about which is |
//| reproduced here. Positions of a window_in-wide window stepping by |
//| `step` over `inputs` values is floor((inputs-window_in)/step)+1. |
//| The Adam kernel's total - ceil((inputs-window_in+step)/step) - |
//| equals that; the Momentum kernel's ceil((inputs-window_in)/step) |
//| is one SHORT whenever step divides the span, so the SGD conv path |
//| silently drops the last sliding position's gradient. That is a |
//| pre-existing defect in the SGD path (ADAM is the default and the |
//| only optimizer these models ship with) and is NOT fixed here - |
//| fixing it would change SGD's unbatched behaviour, which is not |
//| this change's business. The correct count is used because this |
//| accumulator feeds the shared host-side apply for BOTH optimizers. |
//+------------------------------------------------------------------+
__kernel void AccumulateWeightGradConv(__global float *matrix_acc,
__global const float *matrix_g,
__global const float *matrix_i,
const int inputs, int window_in, int window_out, int step)
{
int i = get_global_id(0);
int shift = i % (window_in + 1);
int shift_out = (i - shift) / (window_in + 1);
int total = (inputs - (window_in - step)) % step;
total = (inputs - (window_in - step) - total) / step + (total > 0 ? 1 : 0);
float grad = 0.0f;
for(int t = 0; t < total; t++)
{
if(shift != window_in && (shift + t * step) >= inputs)
break;
grad += matrix_g[t * window_out + shift_out] * (shift == window_in ? 1 : matrix_i[shift + t * step]);
}
matrix_acc[i] += grad;
};
//+------------------------------------------------------------------+
//| dst += src, elementwise. Used by the LSTM's mini-batch path: that |
//| layer's WeightsGradient already holds one sample's complete dW |
//| (summed over the BPTT timesteps), but the seq-backward kernels |
//| MEMSET it at the top of every call, so the running batch total |
//| has to live in a second buffer. Deliberately generic rather than |
//| LSTM-specific - it is just a vector add. |
//+------------------------------------------------------------------+
__kernel void AccumulateBufferInto(__global float *dst,
__global const float *src)
{
const int i = get_global_id(0);
dst[i] += src[i];
};
//+------------------------------------------------------------------+
perf: mini-batch apply becomes a kernel - 8 weight-matrix transfers per batch become 0 Market builds cannot import a DLL, so OpenCL is the tier paying clients run. It was several times slower than the CPU DLL, and the dominant reason was a host-side optimizer step I shipped with the mini-batch work in 274630f. ApplyAccumToBlock read the weights, the accumulator and both Adam moments back over the bus, stepped them in MQL5, and wrote four buffers out - eight full weight-matrix transfers per batch PER WEIGHT BLOCK, each a blocking sync. At TRAIN_BATCH_SIZE 8 that is roughly one entire weight matrix crossing the bus per training sample. It was host-side for a good reason (one optimizer implementation shared by all four tiers instead of four that can drift), and that reason turned out to cost the product's own compute tier. - ApplyAccumAdam / ApplyAccumMomentum in Network.cl: flat, elementwise, and they zero the accumulator themselves so there is no separate clear dispatch and no way to leave it dirty via an early return - ApplyAccumOnDevice dispatches them; the host step stays as the reference and as the implementation for DirectML, the CPU DLL and pure-MQL5 - failure latches OFF process-wide with one warning rather than a failed Execute per batch, since a kernel that did not build will not build later - m_applyKernelsOk is tracked SEPARATELY from m_batchKernelsOk: without the accumulation kernels a device cannot batch and must drop to per-sample updates, whereas without these it batches normally and merely pays the transfers. Conflating them would turn a missing optimisation into a changed optimizer The kernel runs fp32 where the host step ran fp64. That is the OpenCL tier becoming self-consistent, not a regression: its device buffers are already fp32 (CBufferDouble::m_data_f) and its unbatched optimizer already ran in fp32, so the batched path was the odd one out. DLL tiers keep fp64 throughout. Validated: no OpenCL platform exists on this box, so the kernel source is syntax/type checked as C against a shim and driven for 4000 steps. It clears the accumulator, and it is scale-invariant - displacement 1.199336 at |g|=1 versus 1.199333 at |g|=1e-4, matching the figure DirectML/batch_accum_check produced for the fixed CPU_UpdateWeightsAdam. It reproduces the corrected Adam, not the pre-371f8aa one. Both build variants compile 0 errors, 0 warnings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 17:09:52 -04:00
//| MINI-BATCH APPLY - one optimizer step on the accumulated mean. |
//| |
//| These replace a HOST-SIDE apply that read the whole weight matrix,|
//| the accumulator and both Adam moments back over the bus, stepped |
//| them in MQL5, and wrote four buffers out again: eight full-matrix |
//| transfers per batch per weight block, each a blocking sync. At |
//| batch 8 that was ~1 weight-matrix-worth of PCIe traffic PER |
//| SAMPLE, which is why an RX 580 lost to a CPU thread pool on this |
//| workload. Market builds cannot import a DLL, so OpenCL is the |
//| tier clients actually run and this path is the product's speed. |
//| |
//| Flat and elementwise: the accumulator already holds the summed |
//| per-weight gradient (AccumulateWeightGrad above), so there is no |
//| outer product to reconstruct and no float4 dispatch to mirror. |
//| |
//| `scale` is 1/batchCount - the MEAN. The arithmetic is a |
//| line-for-line transcription of UpdateWeightsAdam above, INCLUDING |
//| the squared-back second moment, both clamps and the decoupled |
//| decay, so batch size 1 still reproduces the unbatched kernel. The |
//| host implementation in NeuronOCLBase.mqh remains the reference |
//| for the DLL and pure-MQL5 tiers and must be edited in step. |
//| |
//| The accumulator is zeroed HERE, saving a separate clear dispatch |
//| and guaranteeing it cannot be left dirty for the next batch by an |
//| early return between the step and the clear. |
//+------------------------------------------------------------------+
__kernel void ApplyAccumAdam(__global float *matrix_w,
__global float *matrix_acc,
__global float *matrix_m,
__global float *matrix_v,
const float scale, const float l,
const float b1, const float b2)
{
const int i = get_global_id(0);
const float grad = matrix_acc[i] * scale;
const float wv = matrix_w[i];
const float mt = b1 * matrix_m[i] + (1 - b1) * grad;
// Squared back before re-entering the recursion - see UpdateWeightsAdam for the full account of
// what feeding the stored square root in as the variance did to this engine.
const float vt = sqrt(b2 * matrix_v[i] * matrix_v[i] + (1 - b2) * grad * grad);
const float delta = clamp(l * mt / (vt > 0 ? vt : l * 10) - l * WEIGHT_DECAY * wv,
-MAX_WEIGHT_DELTA, MAX_WEIGHT_DELTA);
matrix_w[i] = clamp(wv + delta, -MAX_WEIGHT, MAX_WEIGHT);
matrix_m[i] = mt;
matrix_v[i] = vt;
matrix_acc[i] = 0.0f;
};
//+------------------------------------------------------------------+
//| SGD/momentum counterpart. Note the delta is NOT clamped to |
//| MAX_WEIGHT_DELTA here - matching UpdateWeightsMomentum and the |
//| host apply, both of which clamp only the resulting weight. Kept |
//| that way deliberately rather than "improved": the batched path |
//| has to reproduce the unbatched one, not diverge from it. |
//+------------------------------------------------------------------+
__kernel void ApplyAccumMomentum(__global float *matrix_w,
__global float *matrix_acc,
__global float *matrix_dw,
const float scale, const float lr, const float momentum)
{
const int i = get_global_id(0);
const float grad = matrix_acc[i] * scale;
const float delta = lr * grad + momentum * matrix_dw[i];
matrix_dw[i] = delta;
matrix_w[i] = clamp(matrix_w[i] + delta, -MAX_WEIGHT, MAX_WEIGHT);
matrix_acc[i] = 0.0f;
};
//+------------------------------------------------------------------+
fix: live trades now use the geometry the gate certifies; perf: BN kernels Three changes, one theme: the trade placed, the trade graded, and the trade computed are now the same trade. 1) GEOMETRY WIRE (correctness, the ranked #1 open issue). The measured barrier pair reached the LABELS only - OpenParams still placed orders at the enum geometry (2*ATR/6*ATR), so the deploy gate certified "reaches 1.62*ATR before 3.33*ATR above break-even" about trades the EA never placed. Published via g_DerivedSlAtrMult/g_DerivedTpAtrMult (ConfidenceBridge, same same-tick contract as the confidence globals, because OpenParams runs on the root signal which has no pointer to the AI filter). Two writers: DeriveBarrierGeometry at era 0, and the .cfg adoption a deployed model takes. Overrides both legs and both Intelligent modes - the certificate is exact or it is nothing. TP is ATR-anchored like the label, NOT risk-relative, so a floor-widened stop cannot reshape the certified target. 2) BATCH NORM RUNS DEVICE-SIDE ON OPENCL. Four kernels in Network.cl - forward, hidden gradient, gamma/beta accumulate, gamma/beta apply - each a line-for-line transcription of the host implementation (NormalizeHost / HiddenGradHost / StepGammaBeta) including every NaN guard, clamp, and the exact moment-write ordering. The host copies remain the runtime for the DLL and pure-MQL5 tiers and the reference the kernels must match. Because this box has no OpenCL platform, the safety story is layered: - shim validation: kernels compiled as C and driven against a fp64 host transcription over NaN-poisoned stats, NaN gamma, over-clamp inputs, the frozen path, both optimizers, 3 batches - ALL PASS, worst normalized diff 0.132 vs tolerance 1.0 - in-situ self-check: each kernel is compared against its host twin ON FIRST USE on the real device (SelfCheckBn*), covering what the shim cannot - arg indices and buffer bindings. Any disagreement resyncs from the good copy, latches all BN kernels off process-wide, and training continues host-side. A transcription bug costs a warning and some speed, never a poisoned .nnw. - sync discipline: BatchOptions is now a CBufferDouble with explicit authority tracking (m_bnDeviceAuthoritative). Checkpoints/saves pull read-only; restores/loads/resets push; a mid-batch handover drains the device gamma/beta accumulator into the host arrays so no sample is lost. 3) SMALL FIXES. Apply-kernel build failure now latches the dispatch path at init (one warning instead of warning + failed Execute). Build tag bumped to win-scoring-gpu-v1 - first tag change since expectancy-stop-v1 despite five binary-changing commits. Both build variants compile 0 errors, 0 warnings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 17:51:40 -04:00
//| BATCH NORM (CNeuronBatchNormOCL), device-side since 2026-08-09. |
//| |
//| This layer computed host-side on every tier, which on OpenCL |
//| meant a blocking read of the previous layer's output and a |
//| blocking write of its own - per sample, per layer, forward AND |
//| backward. These kernels are LINE-FOR-LINE transcriptions of the |
//| host implementations in AI\NeuronBatchNorm.mqh (NormalizeHost, |
//| calcInputGradients, StepGammaBeta), including every NaN guard and |
//| clamp - that layer latched NaN into its persisted statistics once |
//| (2026-08-02) and the guards are what keep a single garbage value |
//| from poisoning the .nnw forever. The host copies remain the |
//| reference AND the runtime for the DLL / pure-MQL5 tiers; edit the |
//| two together. The MQL5 side also self-checks each kernel against |
//| its host twin on first use and latches back to the host path on |
//| any disagreement, so a transcription error costs a warning and |
//| some speed, never a corrupted model. |
//| |
//| Slot layout below MUST match NeuronBatchNorm.mqh's BN_OPT_*. |
//+------------------------------------------------------------------+
#define BN_OPT_STRIDE 9
#define BN_OPT_MEAN 0
#define BN_OPT_VAR 1
#define BN_OPT_NX 2
#define BN_OPT_GAMMA 3
#define BN_OPT_BETA 4
#define BN_OPT_MG 5
#define BN_OPT_MB 6
#define BN_OPT_VG 7
#define BN_OPT_VB 8
#define BN_EPSILON 1.0e-10f
#define BN_MIN_STD 1.0e-4f
#define BN_MAX_INPUT 1.0e6f
//+------------------------------------------------------------------+
//| Forward transform for one unit. `w` is the host-computed |
//| effective EMA window (the 1,2,3..iBatchSize ramp lives host-side |
//| with iSamplesSeen); `frozen` mirrors bStatsFrozen - statistics |
//| are USED but not UPDATED, classic inference semantics. |
//+------------------------------------------------------------------+
__kernel void BatchNormForward(__global const float *matrix_i,
__global float *matrix_o,
__global float *options,
const float w, const int frozen)
{
const int i = get_global_id(0);
const int shift = i * BN_OPT_STRIDE;
float x = matrix_i[i];
// Guards in the same order as NormalizeHost: non-finite -> 0, then the range clamp that keeps the
// statistics arithmetic bounded (a merely-huge finite x overflows (x-mean)^2 long before infinity).
if(isnan(x) || isinf(x))
x = 0.0f;
x = clamp(x, -BN_MAX_INPUT, BN_MAX_INPUT);
float mean = options[shift + BN_OPT_MEAN];
float variance = options[shift + BN_OPT_VAR];
// Self-heal statistics poisoned before the guards existed - Load faithfully restores NaN.
if(isnan(mean) || isinf(mean))
mean = x;
if(isnan(variance) || isinf(variance) || variance < 0.0f)
variance = 0.0f;
if(frozen == 0)
{
// EMA update, new mean used by the variance term - exact order of the host code.
mean = (mean * (w - 1.0f) + x) / w;
variance = (variance * (w - 1.0f) + (x - mean) * (x - mean)) / w;
}
const float delta = x - mean;
const float sd = fmax(sqrt(variance + BN_EPSILON), BN_MIN_STD);
const float nx = delta / sd;
float gamma = options[shift + BN_OPT_GAMMA];
float beta = options[shift + BN_OPT_BETA];
// Local substitution only, exactly like the host forward: the PERSISTED repair happens in the
// gamma/beta step, which is the copy that makes it stick.
if(isnan(gamma) || isinf(gamma))
gamma = 1.0f;
if(isnan(beta) || isinf(beta))
beta = 0.0f;
if(frozen == 0)
{
options[shift + BN_OPT_MEAN] = mean;
options[shift + BN_OPT_VAR] = variance;
}
// nx cached even when frozen - costs nothing, keeps the buffer consistent with the output.
options[shift + BN_OPT_NX] = nx;
matrix_o[i] = gamma * nx + beta;
};
//+------------------------------------------------------------------+
//| Backward for one unit: dL/dx = gamma/sd * dL/dy, then the |
//| PREVIOUS layer's activation derivative applied with the same |
//| clamp-to-range "implied target" treatment CaclHiddenGradient |
//| uses, so from below a batch-norm layer is indistinguishable from |
//| any other. `activation` is the NATIVE code (0=TANH 1=SIGMOID |
//| 2=PRELU, anything else passes through) - NativeActivationCode() |
//| at the dispatch site, never a raw enum cast. |
//+------------------------------------------------------------------+
__kernel void BatchNormHiddenGrad(__global const float *matrix_g,
__global const float *prev_o,
__global float *prev_g,
__global const float *options,
const int activation)
{
const int i = get_global_id(0);
const int shift = i * BN_OPT_STRIDE;
const float sd = fmax(sqrt(options[shift + BN_OPT_VAR] + BN_EPSILON), BN_MIN_STD);
float g = matrix_g[i] * options[shift + BN_OPT_GAMMA] / sd;
const float out = prev_o[i];
switch(activation)
{
case 0:
g = clamp(g + out, -1.0f, 1.0f) - out;
g = g * fmax(MIN_ACTIVATION_DERIVATIVE, 1.0f - out * out);
break;
case 1:
g = clamp(g + out, 0.0f, 1.0f) - out;
g = g * fmax(MIN_ACTIVATION_DERIVATIVE, out * (1.0f - out));
break;
case 2:
g = g * (out >= 0.0f ? 1.0f : 0.01f);
break;
default:
break;
}
prev_g[i] = g;
};
//+------------------------------------------------------------------+
//| Mini-batch accumulate for gamma/beta: dL/dgamma = dL/dy * nx, |
//| dL/dbeta = dL/dy, summed per unit into acc[2i]/acc[2i+1]. Formed |
//| per sample because BN_OPT_NX is overwritten by every forward. |
//+------------------------------------------------------------------+
__kernel void BatchNormAccumGammaBeta(__global const float *matrix_g,
__global const float *options,
__global float *acc)
{
const int i = get_global_id(0);
const float g = matrix_g[i];
acc[2 * i] += g * options[i * BN_OPT_STRIDE + BN_OPT_NX];
acc[2 * i + 1] += g;
};
//+------------------------------------------------------------------+
//| One gamma/beta optimizer step on the accumulated mean, then the |
//| accumulator is zeroed. Transcribed from StepGammaBeta including |
//| its exact write ordering: moments are stored BEFORE the |
//| finiteness check, so a skipped unit still advances its optimizer |
//| state, and the persisted gamma/beta self-heal happens HERE (this |
//| is the copy that sticks). NO WEIGHT DECAY - decaying gamma toward |
//| zero is the collapse this layer exists to prevent. The per-sample |
//| path is this same pair of kernels at scale=1. |
//| `optimizer`: 0 = SGD/momentum (lr=eta, momentum=alpha), else Adam |
//| (lt = bias-corrected step, b1, b2; v stored square-rooted, same |
//| convention as every Adam kernel in this file). |
//+------------------------------------------------------------------+
__kernel void BatchNormApplyGammaBeta(__global float *options,
__global float *acc,
const float scale, const float lt,
const float b1, const float b2,
const float lr, const float momentum,
const int optimizer)
{
const int i = get_global_id(0);
const int shift = i * BN_OPT_STRIDE;
const float gGamma = acc[2 * i] * scale;
const float gBeta = acc[2 * i + 1] * scale;
acc[2 * i] = 0.0f;
acc[2 * i + 1] = 0.0f;
float gamma = options[shift + BN_OPT_GAMMA];
float beta = options[shift + BN_OPT_BETA];
if(isnan(gamma) || isinf(gamma))
gamma = 1.0f;
if(isnan(beta) || isinf(beta))
beta = 0.0f;
float dGamma, dBeta;
if(optimizer == 0)
{
dGamma = lr * gGamma + momentum * options[shift + BN_OPT_MG];
dBeta = lr * gBeta + momentum * options[shift + BN_OPT_MB];
options[shift + BN_OPT_MG] = dGamma;
options[shift + BN_OPT_MB] = dBeta;
}
else
{
const float mg = b1 * options[shift + BN_OPT_MG] + (1.0f - b1) * gGamma;
const float mb = b1 * options[shift + BN_OPT_MB] + (1.0f - b1) * gBeta;
const float vgOld = options[shift + BN_OPT_VG];
const float vbOld = options[shift + BN_OPT_VB];
const float vg = sqrt(b2 * vgOld * vgOld + (1.0f - b2) * gGamma * gGamma);
const float vb = sqrt(b2 * vbOld * vbOld + (1.0f - b2) * gBeta * gBeta);
dGamma = lt * mg / (vg > 0.0f ? vg : lt * 10.0f);
dBeta = lt * mb / (vb > 0.0f ? vb : lt * 10.0f);
options[shift + BN_OPT_MG] = mg;
options[shift + BN_OPT_MB] = mb;
options[shift + BN_OPT_VG] = vg;
options[shift + BN_OPT_VB] = vb;
}
dGamma = clamp(dGamma, -MAX_WEIGHT_DELTA, MAX_WEIGHT_DELTA);
dBeta = clamp(dBeta, -MAX_WEIGHT_DELTA, MAX_WEIGHT_DELTA);
// A non-finite delta means SKIP this unit's parameter write, never poison it - the moments above
// are already stored, matching the host's ordering exactly.
if(isnan(dGamma) || isinf(dGamma) || isnan(dBeta) || isinf(dBeta))
return;
options[shift + BN_OPT_GAMMA] = clamp(gamma + dGamma, -MAX_WEIGHT, MAX_WEIGHT);
options[shift + BN_OPT_BETA] = clamp(beta + dBeta, -MAX_WEIGHT, MAX_WEIGHT);
};
//+------------------------------------------------------------------+
//| LSTM (CNeuronLSTMOCL) - derived from scratch from the standard |
//| LSTM equations (NOT ported from any reference implementation). |
//| Single-timestep-truncated BPTT: gradient does not flow back into |
//| h_prev/c_prev from a previous step - only this step's gates and |
//| weights receive gradient. Adam-only optimizer. |
//| Weight layout: 4 gates (order: forget, input, output, candidate), |
//| each a block of H*(I+H+1) floats - per hidden unit: H weights on |
//| h_prev, I weights on the current input, 1 bias. |
//+------------------------------------------------------------------+
__kernel void LSTM_Gates(__global float *matrix_w,
__global float *hidden_prev,
__global float *inputs,
__global float *concatenated,
int hidden_size, int input_size)
{
int id = get_global_id(0);
int gate = get_global_id(1);
int per_gate = hidden_size * (hidden_size + input_size + 1);
int shift = gate * per_gate + id * (hidden_size + input_size + 1);
float sum = 0.0f;
for(int k = 0; k < hidden_size; k++)
sum += hidden_prev[k] * matrix_w[shift + k];
for(int k = 0; k < input_size; k++)
sum += inputs[k] * matrix_w[shift + hidden_size + k];
sum += matrix_w[shift + hidden_size + input_size];
float val = (gate < 3) ? (1.0f / (1.0f + exp(-sum))) : tanh(sum);
concatenated[gate * hidden_size + id] = val;
}
//+------------------------------------------------------------------+
//| |
//+------------------------------------------------------------------+
__kernel void LSTM_State(__global float *concatenated,
__global float *memory,
__global float *hidden_prev,
__global float *hidden_cache,
__global float *output,
int hidden_size)
{
int id = get_global_id(0);
float f = concatenated[id];
float ii = concatenated[hidden_size + id];
float o = concatenated[2 * hidden_size + id];
float g = concatenated[3 * hidden_size + id];
float c_prev = memory[id];
memory[hidden_size + id] = c_prev;
float c_t = f * c_prev + ii * g;
memory[id] = c_t;
hidden_cache[id] = hidden_prev[id];
output[id] = o * tanh(c_t);
}
//+------------------------------------------------------------------+
//| |
//+------------------------------------------------------------------+
__kernel void LSTM_GateGradient(__global float *gradient,
__global float *memory,
__global float *concatenated,
__global float *concatenated_gradient,
int hidden_size)
{
int id = get_global_id(0);
float c_t = memory[id];
float c_prev = memory[hidden_size + id];
float f = concatenated[id];
float ii = concatenated[hidden_size + id];
float o = concatenated[2 * hidden_size + id];
float g = concatenated[3 * hidden_size + id];
float t = tanh(c_t);
float dh = gradient[id];
// Derivative floor on every gate below - see MIN_ACTIVATION_DERIVATIVE's declaration comment.
// LSTM gates are sigmoid/tanh by design and routinely sit near their saturated extremes (a fully
// "open" or "closed" gate), which is exactly where an unfloored derivative goes to zero and
// blocks gradient from flowing through the recurrent path at all.
float dc = dh * o * fmax(MIN_ACTIVATION_DERIVATIVE, 1 - t * t);
concatenated_gradient[2 * hidden_size + id] = dh * t * fmax(MIN_ACTIVATION_DERIVATIVE, o * (1 - o)); // output gate
concatenated_gradient[id] = dc * c_prev * fmax(MIN_ACTIVATION_DERIVATIVE, f * (1 - f)); // forget gate
concatenated_gradient[hidden_size + id] = dc * g * fmax(MIN_ACTIVATION_DERIVATIVE, ii * (1 - ii)); // input gate
concatenated_gradient[3 * hidden_size + id] = dc * ii * fmax(MIN_ACTIVATION_DERIVATIVE, 1 - g * g); // candidate
}
//+------------------------------------------------------------------+
//| |
//+------------------------------------------------------------------+
__kernel void LSTM_WeightsGradient(__global float *concatenated_gradient,
__global float *hidden_cache,
__global float *inputs,
__global float *weights_gradient,
int hidden_size, int input_size)
{
int wi = get_global_id(0);
int per_gate = hidden_size * (hidden_size + input_size + 1);
int gate = wi / per_gate;
int rem = wi % per_gate;
int id = rem / (hidden_size + input_size + 1);
int k = rem % (hidden_size + input_size + 1);
float inp = (k < hidden_size ? hidden_cache[k] : (k < hidden_size + input_size ? inputs[k - hidden_size] : 1.0f));
weights_gradient[wi] = concatenated_gradient[gate * hidden_size + id] * inp;
}
//+------------------------------------------------------------------+
//| |
//+------------------------------------------------------------------+
__kernel void LSTM_InputsGradient(__global float *concatenated_gradient,
__global float *matrix_w,
__global float *inputs_gradient,
int hidden_size, int input_size)
{
int j = get_global_id(0);
int per_gate = hidden_size * (hidden_size + input_size + 1);
float sum = 0.0f;
for(int gate = 0; gate < 4; gate++)
for(int id = 0; id < hidden_size; id++)
sum += concatenated_gradient[gate * hidden_size + id] * matrix_w[gate * per_gate + id * (hidden_size + input_size + 1) + hidden_size + j];
inputs_gradient[j] = sum;
feat(ai): sequence-LSTM kernels for the OpenCL tier Closes the gap left by 7a08197, which refused sequence mode under OpenCL. That was defensible for a private build and not for a shipped one: the release path includes an OpenCL laptop, and a customer with a GPU would have found LSTM and HYBRID simply unavailable. One launch PER TIMESTEP rather than a single kernel looping with barrier(). Every hidden unit's gates read all of h_{t-1}, OpenCL barriers only span a work-group, and nothing here constrains how the runtime partitions the global size - so an in-kernel loop would be correct only by luck of the partitioning. Host-driven launches make each step an implicit global barrier: more enqueues, correct on every device. Backward reuses the buffers the single-timestep path leaves idle in sequence mode - ConcatenatedGradient (4H) for gate gradients, HiddenCache (H) for dh, Memory (2H) for dc - so BPTT costs no extra allocations. dW is zeroed once and accumulated across steps, matching the fused DLL kernel. Verification available on this machine has limits worth recording. The math is the same as CPU_LSTMSeqForward/Backward, which is gradient-checked to 2.3e-10; the kernels are syntax/type-checked offline (DirectML\opencl_seq_syntax_check.cpp, compiled as C++ with OpenCL shims) because there is no OpenCL device or ICD here. That check exists because a typo in Network.cl fails the WHOLE program build, which would take the dense and conv kernels down with it - not just the new ones. KernelCreate results are now checked and reported for these four for the same reason; a build failure degrades to "LSTM/HYBRID unavailable on this device" instead of an Execute error mid-training. STILL NEEDS A RUN ON REAL OPENCL HARDWARE before release. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:40:56 -04:00
}
//+------------------------------------------------------------------+
//| |
//+------------------------------------------------------------------+
//+------------------------------------------------------------------+
//| Sequence LSTM - ONE LAUNCH PER TIMESTEP. |
//| |
//| The recurrence is sequential: step t needs h/c from step t-1, and |
//| every hidden unit's gates read ALL of h_{t-1}. OpenCL can only |
//| barrier WITHIN a work-group, and nothing here controls how the |
//| runtime partitions the global size, so a single-launch loop with |
//| barrier() would be correct only by luck. Driving the loop from |
//| the host instead makes each launch an implicit global barrier - |
//| slower per step, correct on every device. |
//| |
//| Layouts match CPU_LSTMSeqForward in DirectML\WarriorCPU.cpp (the |
//| authoritative contract): gate order [f,i,o,g]; weight row `hid` |
//| of a gate is [H recurrent | Iw input | 1 bias]; caches are |
//| timestep-major. h_{-1} and c_{-1} are zero. |
//+------------------------------------------------------------------+
__kernel void LSTM_SeqStepForward(__global float *matrix_w,
__global float *inputs,
__global float *cache_gates,
__global float *cache_cell,
__global float *cache_hidden,
__global float *output,
int hidden_size,
int step_inputs,
int steps,
int t)
{
int id = get_global_id(0);
int H = hidden_size;
int Iw = step_inputs;
int row = H + Iw + 1;
int per_gate = H * row;
__global float *x = inputs + t * Iw;
float g[4];
for(int gate = 0; gate < 4; gate++)
{
int shift = gate * per_gate + id * row;
float sum = 0;
if(t > 0)
{
__global float *h_prev = cache_hidden + (t - 1) * H;
for(int k = 0; k < H; k++)
sum += h_prev[k] * matrix_w[shift + k];
}
for(int k = 0; k < Iw; k++)
sum += x[k] * matrix_w[shift + H + k];
sum += matrix_w[shift + H + Iw];
g[gate] = (gate < 3) ? (1 / (1 + exp(-sum))) : tanh(sum);
cache_gates[t * 4 * H + gate * H + id] = g[gate];
}
float c_prev = (t > 0) ? cache_cell[(t - 1) * H + id] : 0;
float c_t = g[0] * c_prev + g[1] * g[3];
cache_cell[t * H + id] = c_t;
float h_t = g[2] * tanh(c_t);
cache_hidden[t * H + id] = h_t;
//--- the layer's visible output is the LAST hidden state
if(t == steps - 1)
output[id] = h_t;
}
//+------------------------------------------------------------------+
//| BPTT step 1/3: gate gradients for step t, and dc for step t-1. |
//| dh comes from the layer above at t == steps-1 and from the |
//| recurrence (dh_buf, written by LSTM_SeqStepInputGrad) otherwise. |
//| dc_buf must be zeroed by the host before the first step. |
//+------------------------------------------------------------------+
__kernel void LSTM_SeqStepGateGrad(__global float *out_gradient,
__global float *dh_buf,
__global float *dc_buf,
__global float *cache_gates,
__global float *cache_cell,
__global float *gate_grad,
int hidden_size,
int steps,
int t)
{
int id = get_global_id(0);
int H = hidden_size;
__global float *gt = cache_gates + t * 4 * H;
float f = gt[id];
float ii = gt[H + id];
float o = gt[2 * H + id];
float g = gt[3 * H + id];
float c_t = cache_cell[t * H + id];
float c_prev = (t > 0) ? cache_cell[(t - 1) * H + id] : 0;
float tc = tanh(c_t);
float dh = (t == steps - 1) ? out_gradient[id] : dh_buf[id];
//--- the term the per-step kernels could never express: dc arriving from step t+1
float dcTot = dh * o * fmax(MIN_ACTIVATION_DERIVATIVE, 1 - tc * tc) + dc_buf[id];
gate_grad[2 * H + id] = dh * tc * fmax(MIN_ACTIVATION_DERIVATIVE, o * (1 - o));
gate_grad[id] = dcTot * c_prev * fmax(MIN_ACTIVATION_DERIVATIVE, f * (1 - f));
gate_grad[H + id] = dcTot * g * fmax(MIN_ACTIVATION_DERIVATIVE, ii * (1 - ii));
gate_grad[3 * H + id] = dcTot * ii * fmax(MIN_ACTIVATION_DERIVATIVE, 1 - g * g);
dc_buf[id] = dcTot * f;
}
//+------------------------------------------------------------------+
//| BPTT step 2/3: ACCUMULATE dW for step t. Accumulates rather than |
//| assigns because the gate weights are shared by every timestep - |
//| the host zeroes weights_gradient once before the backward loop. |
//+------------------------------------------------------------------+
__kernel void LSTM_SeqStepWeightGrad(__global float *gate_grad,
__global float *cache_hidden,
__global float *inputs,
__global float *weights_gradient,
int hidden_size,
int step_inputs,
int t)
{
int wi = get_global_id(0);
int H = hidden_size;
int Iw = step_inputs;
int row = H + Iw + 1;
int per_gate = H * row;
int gate = wi / per_gate;
int rem = wi % per_gate;
int hid = rem / row;
int k = rem % row;
float inp;
if(k < H)
inp = (t > 0) ? cache_hidden[(t - 1) * H + k] : 0;
else
if(k < H + Iw)
inp = inputs[t * Iw + (k - H)];
else
inp = 1;
weights_gradient[wi] += gate_grad[gate * H + hid] * inp;
}
//+------------------------------------------------------------------+
//| BPTT step 3/3: dx_t (this step's slice of the input gradient) and |
//| dh_{t-1} (the recurrent path). One flat range over both so a |
//| narrow per-step input still saturates the device. |
//+------------------------------------------------------------------+
__kernel void LSTM_SeqStepInputGrad(__global float *gate_grad,
__global float *matrix_w,
__global float *inputs_gradient,
__global float *dh_buf,
int hidden_size,
int step_inputs,
int t)
{
int j = get_global_id(0);
int H = hidden_size;
int Iw = step_inputs;
int row = H + Iw + 1;
int per_gate = H * row;
int col = (j < Iw) ? (H + j) : (j - Iw);
float sum = 0;
for(int gate = 0; gate < 4; gate++)
for(int hid = 0; hid < H; hid++)
sum += gate_grad[gate * H + hid] * matrix_w[gate * per_gate + hid * row + col];
if(j < Iw)
inputs_gradient[t * Iw + j] = sum;
else
dh_buf[j - Iw] = sum;
}
//+------------------------------------------------------------------+
//| |
//+------------------------------------------------------------------+
__kernel void LSTM_UpdateWeightsAdam(__global float *matrix_w,
__global float *weights_gradient,
__global float *matrix_m,
__global float *matrix_v,
float l, float b1, float b2)
{
int wi = get_global_id(0);
float g = weights_gradient[wi];
float mt = b1 * matrix_m[wi] + (1 - b1) * g;
fix: the Adam second moment was never Adam - all four tiers Root cause of the B=32 regression, and it predates F4 entirely. Every Adam kernel stored v already square-rooted and then fed that stored value back in as if it were the variance: v_new = sqrt(b2 * v_old + (1 - b2) * g^2) That recursion has a fixed point at v ~= b2 = 0.999 for ANY gradient below unit scale, so the denominator stops tracking the gradient and Adam degrades into plain SGD with lr = lt. Measured against the shipped WarriorCPU.dll (batch_accum_check.cpp, TestOptimizerScaleInvariance), 4000 steps of a constant gradient: 3285x less displacement at |g|=1e-5 than at |g|=1, where a scale-invariant optimizer gives the same distance for both. After the fix all six magnitudes read 1.199 and v tracks |g| exactly. It hit conv/LSTM specifically because they sit behind a batch-norm with running variance ~2.6e+05, so their gradients arrive divided by ~500 - deep in the degraded regime - while the dense stack near the loss stayed in the working one. In situ on SP500 H1: lstm1 dW/W 2.62/10.0/7.14% -> 0.024/0.022/ 0.003%, conv1 decaying to 0.000% by era 30. NeuronBatchNorm.mqh already squared v back for gamma/beta and its comment named the kernels as wrong, which is exactly why gamma/beta kept training while the stages behind froze. Persisted .nnw needs no migration - v keeps its std-dev meaning. Also, the two ways F4 exposed it, both mine: - No LR compensation for B fewer steps per era. sqrt(B) for adaptive methods (Krizhevsky 2014; Granziol et al. 2022), applied once in InitialEtaForOptimizer(). Linear scaling (Goyal et al. 2017) is for SGD. - Plateau patience denominated in eras, so raising B made the ladder 32x more impatient in its only unit. PAI converged at era 41 on ~49k updates where the same config had been finding new bests at era 1028. TrainPlateauPatienceEras() stretches it by the same sqrt(B). TRAIN_BATCH_SIZE 32 -> 8 so the patience stretch stays affordable (8 -> 23 eras per stage, not 8 -> 45). Both helpers are identities at B=1. Deploy gate: DEPLOY_MIN_SIDE_RECALL_PCT (10%) folded into tradeableOK. The perceptron reported Sell:0% recall in all 41 eras, cleared the floor on Buy alone at 36.6% vs 34% chance, deployed, and sprayed buy arrows. Folded into the ranking key rather than checked at deploy time so a one-sided era cannot become best-so-far in the first place. Deinit: the arrow purge now runs BEFORE ExtPanel.Destroy(), an unbounded CAppDialog teardown that sat ahead of it - the same ordering inversion the rule there exists to prevent. CONV was force-terminated 4.8 s into OnDeinit (vs ~1.1 s for the three that finished) having reached none of its cleanup, so its arrows stayed on the chart. Steps are now timed in the log. PurgeChart's verification rescan filtered on OBJ_ARROW, the same blind spot as the bulk delete, so "persisted 10 ... cleared 0" passed silently. It now walks every object type and reports the object counts when both are zero. Both build variants compile 0 errors / 0 warnings; both DLLs rebuilt. FORCES A RETRAIN (already forced by N1) and both DLLs must ship with the .ex5. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 14:02:35 -04:00
// v squared back before the recursion - see UpdateWeightsAdam's comment above for why.
float vt = sqrt(b2 * matrix_v[wi] * matrix_v[wi] + (1 - b2) * g * g);
float delta = clamp(l * mt / (vt > 0 ? vt : l * 10) - l * WEIGHT_DECAY * matrix_w[wi], -MAX_WEIGHT_DELTA, MAX_WEIGHT_DELTA);
// Sign-agreement gate removed - see UpdateWeightsAdam's comment above for why.
matrix_w[wi] = clamp(matrix_w[wi] + delta, -MAX_WEIGHT, MAX_WEIGHT);
matrix_m[wi] = mt;
matrix_v[wi] = vt;
};
//+------------------------------------------------------------------+
//| SGD+momentum counterpart to LSTM_UpdateWeightsAdam above - same |
//| flat (already-elementwise) weights_gradient/matrix_dw layout, just|
//| the classic heavy-ball update instead of Adam's per-parameter step|
//+------------------------------------------------------------------+
__kernel void LSTM_UpdateWeightsMomentum(__global float *matrix_w,
__global float *weights_gradient,
__global float *matrix_dw,
float learning_rates, float momentum, int optimizer)
{
int wi = get_global_id(0);
// `optimizer` retained unused - see UpdateWeightsMomentum's comment above.
float delta = learning_rates * weights_gradient[wi] + momentum * matrix_dw[wi];
matrix_dw[wi] = delta;
matrix_w[wi] = clamp(matrix_w[wi] + delta, -MAX_WEIGHT, MAX_WEIGHT);
};