Warrior_EA/DirectML/dense_backprop_check.cpp
AnimateDread ea9d86b3ee 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

209 lines
8.4 KiB
C++

//+------------------------------------------------------------------+
//| dense_backprop_check.cpp |
//| |
//| Offline math check for the 2026-08-11 dense hidden-gradient |
//| transpose fix. Same shape as batch_accum_check beside it: link |
//| straight against WarriorCPU.dll, drive the real exports, compare |
//| against an independent reference computed here. |
//| |
//| WHAT THIS PROVES, precisely: |
//| 1. CPU_CalcHiddenGradient is CONSISTENT WITH CPU_FeedForward: |
//| with no activation on either side, the backward kernel's |
//| ig[i] equals the central finite difference of |
//| J(x) = Sum_k g_k * forward(x)_k through the REAL forward |
//| kernel, on square AND non-square layer pairs. This is the |
//| exact property the pre-fix kernel violated: it read the |
//| weight matrix transposed (square case) or mis-strided |
//| (non-square case), so its output was NOT the gradient of any |
//| function the forward pass computes. |
//| 2. The activation-derivative branches (tanh / sigmoid / PReLU) |
//| match a direct transcription of the kernel's documented |
//| clamp-reformulated formulas applied to the correctly-indexed |
//| propagated sum. |
//| |
//| WHAT IT DOES NOT PROVE - see [[feedback_verify_in_situ_not_ |
//| offline]]: that a layer TRAINS in the assembled network, and |
//| nothing about the OpenCL/DirectML mirrors (those share the fixed |
//| indexing by transcription; the in-situ dW/W report on a real era |
//| is the check that covers whichever tier actually runs). |
//| |
//| Build (from a plain cmd, after build_cpu.bat): |
//| cl /nologo /EHsc /O2 /std:c++17 dense_backprop_check.cpp WarriorCPU.lib
//+------------------------------------------------------------------+
#include <cstdio>
#include <cmath>
#include <vector>
#include <random>
#include <algorithm>
// WarriorCPU.h is deliberately NOT included - see batch_accum_check.cpp for why.
// Signatures must match WarriorCPU.h exactly.
typedef long long CpuHandle;
extern "C"
{
__declspec(dllimport) CpuHandle __stdcall CPU_Init(int threads);
__declspec(dllimport) void __stdcall CPU_Shutdown(CpuHandle ctx);
__declspec(dllimport) int __stdcall CPU_BufferCreate(CpuHandle ctx, int elementCount);
__declspec(dllimport) int __stdcall CPU_BufferWrite(CpuHandle ctx, int handle, const double *data, int count);
__declspec(dllimport) int __stdcall CPU_BufferRead(CpuHandle ctx, int handle, double *data, int count);
__declspec(dllimport) void __stdcall CPU_BufferFree(CpuHandle ctx, int handle);
__declspec(dllimport) int __stdcall CPU_FeedForward(CpuHandle ctx, int wHandle, int iHandle, int oHandle,
int inputs, int activation);
__declspec(dllimport) int __stdcall CPU_CalcHiddenGradient(CpuHandle ctx, int wHandle, int gHandle, int oHandle,
int igHandle, int outputs, int activation, int count);
}
namespace
{
// Must match WarriorCPU.cpp (NOT AI\Network.cl's 1e-3f - the CPU DLL floors at 1e-4).
const double MIN_ACTIVATION_DERIVATIVE = 1.0e-4;
int g_failures = 0;
void CheckMax(const char *what, double maxDiff, double tol)
{
if(!(maxDiff <= tol))
{
std::printf(" FAIL %-52s max |diff| %.3g > %.3g\n", what, maxDiff, tol);
++g_failures;
}
else
std::printf(" ok %-52s max |diff| %.3g\n", what, maxDiff);
}
// J(x) = Sum_k g_k * forward(x)_k through the real forward kernel, activation NONE (-1).
double ScalarJ(CpuHandle ctx, int wH, int iH, int oH, int thisN, int nextN,
const std::vector<double> &x, const std::vector<double> &g)
{
CPU_BufferWrite(ctx, iH, x.data(), thisN);
if(!CPU_FeedForward(ctx, wH, iH, oH, thisN, -1))
{
std::printf(" FAIL CPU_FeedForward returned 0\n");
++g_failures;
return 0.0;
}
std::vector<double> out(nextN);
CPU_BufferRead(ctx, oH, out.data(), nextN);
double j = 0.0;
for(int k = 0; k < nextN; k++)
j += g[k] * out[k];
return j;
}
void RunPair(CpuHandle ctx, int thisN, int nextN, std::mt19937 &rng)
{
std::uniform_real_distribution<double> uni(-1.0, 1.0);
std::vector<double> w((size_t)(thisN + 1) * nextN), x(thisN), g(nextN);
for(auto &v : w) v = uni(rng);
for(auto &v : x) v = uni(rng);
for(auto &v : g) v = uni(rng);
int wH = CPU_BufferCreate(ctx, (int)w.size());
int iH = CPU_BufferCreate(ctx, thisN);
int oH = CPU_BufferCreate(ctx, nextN);
int gH = CPU_BufferCreate(ctx, nextN);
int outH = CPU_BufferCreate(ctx, thisN); // this layer's own output vector (= x here)
int igH = CPU_BufferCreate(ctx, thisN);
CPU_BufferWrite(ctx, wH, w.data(), (int)w.size());
CPU_BufferWrite(ctx, gH, g.data(), nextN);
CPU_BufferWrite(ctx, outH, x.data(), thisN);
//--- 1) forward/backward consistency by central finite differences, activation NONE
if(!CPU_CalcHiddenGradient(ctx, wH, gH, outH, igH, nextN, -1, thisN))
{
std::printf(" FAIL CPU_CalcHiddenGradient returned 0 (%dx%d)\n", thisN, nextN);
++g_failures;
return;
}
std::vector<double> ig(thisN);
CPU_BufferRead(ctx, igH, ig.data(), thisN);
const double h = 1.0e-6;
double maxDiff = 0.0;
for(int i = 0; i < thisN; i++)
{
std::vector<double> xp = x, xm = x;
xp[i] += h;
xm[i] -= h;
double fd = (ScalarJ(ctx, wH, iH, oH, thisN, nextN, xp, g)
- ScalarJ(ctx, wH, iH, oH, thisN, nextN, xm, g)) / (2.0 * h);
maxDiff = std::max(maxDiff, std::fabs(fd - ig[i]));
}
char label[96];
std::snprintf(label, sizeof(label), "fd-vs-kernel %dx%d (activation NONE)", thisN, nextN);
CheckMax(label, maxDiff, 1e-7);
//--- 2) activation branches vs a direct transcription on the correctly-indexed sum
for(int act = 0; act <= 2; act++)
{
//--- for tanh/sigmoid the stored output must be inside the activation's range or the
//--- clamp-reformulation departs from the plain derivative by design; keep |o| < 1.
std::vector<double> o(thisN);
for(auto &v : o) v = 0.9 * uni(rng);
CPU_BufferWrite(ctx, outH, o.data(), thisN);
if(!CPU_CalcHiddenGradient(ctx, wH, gH, outH, igH, nextN, act, thisN))
{
std::printf(" FAIL CPU_CalcHiddenGradient returned 0 (act %d)\n", act);
++g_failures;
continue;
}
CPU_BufferRead(ctx, igH, ig.data(), thisN);
double maxD = 0.0;
for(int i = 0; i < thisN; i++)
{
double sum = 0.0;
for(int k = 0; k < nextN; k++)
sum += g[k] * w[(size_t)k * (thisN + 1) + i];
double ov = o[i];
if(act == 0)
{
sum = std::max(-1.0, std::min(1.0, sum + ov)) - ov;
sum *= std::max(MIN_ACTIVATION_DERIVATIVE, 1.0 - ov * ov);
}
else if(act == 1)
{
sum = std::max(0.0, std::min(1.0, sum + ov)) - ov;
sum *= std::max(MIN_ACTIVATION_DERIVATIVE, ov * (1.0 - ov));
}
else if(act == 2 && ov < 0.0)
sum *= 0.01;
maxD = std::max(maxD, std::fabs(sum - ig[i]));
}
std::snprintf(label, sizeof(label), "activation branch %d, %dx%d", act, thisN, nextN);
CheckMax(label, maxD, 1e-12);
}
CPU_BufferFree(ctx, wH);
CPU_BufferFree(ctx, iH);
CPU_BufferFree(ctx, oH);
CPU_BufferFree(ctx, gH);
CPU_BufferFree(ctx, outH);
CPU_BufferFree(ctx, igH);
}
} // namespace
int main()
{
CpuHandle ctx = CPU_Init(2);
if(!ctx)
{
std::printf("CPU_Init failed\n");
return 1;
}
std::mt19937 rng(20260811);
std::printf("dense_backprop_check:\n");
//--- square (the transpose case), the real head boundary (wide -> 3), a taper boundary, and a
//--- width that is a multiple of 4 (the OpenCL Adam bias-skip shape, kept here for coverage).
RunPair(ctx, 8, 8, rng);
RunPair(ctx, 64, 3, rng);
RunPair(ctx, 33, 64, rng);
RunPair(ctx, 5, 3, rng);
CPU_Shutdown(ctx);
if(g_failures)
{
std::printf("%d FAILURE(S)\n", g_failures);
return 1;
}
std::printf("all checks passed\n");
return 0;
}