Warrior_EA/DirectML/lstm_seq_flowcheck.cpp

136 lines
5.7 KiB
C++
Raw Permalink Normal View History

fix(ai): positive forget-gate bias, giving the LSTM its window back The sequence rewrite made LSTM/HYBRID recurrences over 20 bars, but every weight - including the gate biases - is initialized around zero. That puts the forget gate at sigmoid(0) = 0.5, so the cell state is halved every step: the first bar survives into the output scaled by ~0.5^20, and the gradient reaches it scaled by the same factor. The layer was therefore a one-bar model wearing a 20-bar interface. A one-bar model has no signal on this task, so the head learned the base rate and emitted Neutral everywhere - the flat 0.34 IS error across twelve eras and OOS recall Neutral:100% seen on SP500 H1. Measured at the shipped H1 shapes (H=64, stepInputs=21, T=20) - influence of bar 0 on the output relative to bar 19: bias 0.0 -> 3.0e-05 forward, 3.3e-05 backward (dead) bias 1.0 -> 1.2e-02 forward, 1.4e-02 backward bias 2.0 -> 2.5e-01 forward, 2.7e-01 backward (a real 20-bar field) This is the standard fix, not a tuned knob: Gers/Schmidhuber/Cummins (2000) introduced the forget gate with a positive bias, and Jozefowicz/Zaremba/ Sutskever (ICML 2015) recommend a bias of 1 as a default (whence Keras' unit_forget_bias). Both 1 and 2 are standard; the sweep picks 2 because at a 20-step window a bias of 1 still leaves the oldest bar at ~1% influence. lstm_seq_flowcheck.cpp is added as a permanent regression check and asserts the shipped constant keeps >=5% reach in both directions. It complements lstm_seq_gradcheck.cpp: that one proves the BPTT is CORRECT, this one proves it is USABLE. The gradient check passed at 2.3e-10 throughout - correct math over a recurrence that carries nothing looks exactly like a bad architecture. Both builds compile 0 errors, 0 warnings. Initialization only, so the .nnw format is unchanged; LSTM and HYBRID must retrain to pick it up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:15:29 -04:00
// Signal/gradient flow diagnostic for the sequence-LSTM kernels, at PRODUCTION shapes.
//
// Why this exists (separate from lstm_seq_gradcheck.cpp): that check proves the BPTT
// math is CORRECT. Correct is not the same as USABLE. A textbook-correct LSTM whose
// forget gate sits at sigmoid(0)=0.5 multiplies the cell state by 0.5 every step, so
// over a 20-bar window it retains 1e-6 of bar 0 in the forward pass and propagates
// 1e-6 of the gradient back to it. It trains to a constant and the training log shows
// a flat IS error - exactly what LSTM/HYBRID did on 2026-07-30.
//
// This measures both directions at the real shapes and sweeps the forget-gate bias:
// fwd - how much h_T moves when ONLY bar t is perturbed (memory reach)
// bwd - ||dL/dx_t|| (credit assignment reach)
//
// Build (VS x64 dev prompt, this folder):
// cl /nologo /EHsc /O2 /std:c++17 lstm_seq_flowcheck.cpp WarriorCPU.lib
#include <cstdio>
#include <cmath>
#include <vector>
#include <random>
#include "WarriorCPU.h"
static const int H = 64; // derived hidden size at H1 defaults (see ComputeLstmHiddenSize)
static const int IW = 21; // per-bar feature count for the plain-LSTM topology
static const int T = 20; // m_historyBars
int main()
{
CpuHandle h = CPU_Init(2);
const int perGate = H * (H + IW + 1);
const int wTotal = 4 * perGate;
const int bias = H + IW; // bias column within a row
int wB = CPU_BufferCreate(h, wTotal);
int inB = CPU_BufferCreate(h, T * IW);
int cgB = CPU_BufferCreate(h, T * 4 * H);
int ccB = CPU_BufferCreate(h, T * H);
int chB = CPU_BufferCreate(h, T * H);
int outB = CPU_BufferCreate(h, H);
int ogB = CPU_BufferCreate(h, H);
int wgB = CPU_BufferCreate(h, wTotal);
int igB = CPU_BufferCreate(h, T * IW);
std::mt19937 rng(1234);
// LeCun-uniform, matching CNeuronLSTMOCL::SetInputs exactly.
const double scale = 1.0 / std::sqrt((double)(H + IW) + 1.0);
std::uniform_real_distribution<double> wd(-scale, scale);
std::vector<double> w0((size_t)wTotal);
for(int i = 0; i < wTotal; i++)
w0[i] = wd(rng);
std::normal_distribution<double> xd(0.0, 1.0);
std::vector<double> x((size_t)(T * IW));
for(int i = 0; i < T * IW; i++)
x[i] = xd(rng);
std::vector<double> dL((size_t)H, 0.0);
for(int k = 0; k < H; k++)
dL[k] = ((k % 7) - 3) / 3.0; // asymmetric upstream gradient
// Must track LSTM_FORGET_BIAS_INIT in AI\Network.mqh. The sweep exists to show WHY that value is
// what it is; the verdict at the end is what makes this a regression test rather than a printout.
const double SHIPPED = 2.0;
const double MIN_REACH = 0.05; // oldest bar must retain >=5% of the newest bar's influence
double shippedFwd = 0.0, shippedBwd = 0.0;
const double fb[] = { 0.0, 1.0, SHIPPED };
std::printf("shapes: H=%d IW=%d T=%d weights=%d\n\n", H, IW, T, wTotal);
for(double b : fb)
{
std::vector<double> w = w0;
for(int hid = 0; hid < H; hid++)
w[(size_t)(0 * perGate + hid * (H + IW + 1) + bias)] = b; // gate 0 == forget
CPU_BufferWrite(h, wB, w.data(), wTotal);
CPU_BufferWrite(h, inB, x.data(), T * IW);
CPU_LSTMSeqForward(h, wB, inB, cgB, ccB, chB, outB, H, IW, T);
std::vector<double> base((size_t)H);
CPU_BufferRead(h, outB, base.data(), H);
// --- forward reach: perturb ONLY bar t, measure ||dh_T||
double fwd0 = 0.0, fwdLast = 0.0;
for(int t : {0, T - 1})
{
std::vector<double> xp = x;
for(int j = 0; j < IW; j++)
xp[(size_t)(t * IW + j)] += 0.01;
CPU_BufferWrite(h, inB, xp.data(), T * IW);
CPU_LSTMSeqForward(h, wB, inB, cgB, ccB, chB, outB, H, IW, T);
std::vector<double> pert((size_t)H);
CPU_BufferRead(h, outB, pert.data(), H);
double s = 0.0;
for(int k = 0; k < H; k++)
s += (pert[k] - base[k]) * (pert[k] - base[k]);
(t == 0 ? fwd0 : fwdLast) = std::sqrt(s);
}
// --- backward reach: ||dL/dx_t|| at the first and last step
CPU_BufferWrite(h, inB, x.data(), T * IW);
CPU_LSTMSeqForward(h, wB, inB, cgB, ccB, chB, outB, H, IW, T);
CPU_BufferWrite(h, ogB, dL.data(), H);
CPU_LSTMSeqBackward(h, wB, inB, cgB, ccB, chB, ogB, wgB, igB, H, IW, T);
std::vector<double> dX((size_t)(T * IW));
CPU_BufferRead(h, igB, dX.data(), T * IW);
double n0 = 0.0, nL = 0.0;
for(int j = 0; j < IW; j++)
{
n0 += dX[(size_t)j] * dX[(size_t)j];
nL += dX[(size_t)((T - 1) * IW + j)] * dX[(size_t)((T - 1) * IW + j)];
}
n0 = std::sqrt(n0);
nL = std::sqrt(nL);
std::printf("forget-gate bias %+.1f (sigmoid=%.3f)\n", b, 1.0 / (1.0 + std::exp(-b)));
std::printf(" fwd ||dh_T|| from bar 0 = %.3e from bar %d = %.3e ratio %.2e\n",
fwd0, T - 1, fwdLast, fwdLast > 0 ? fwd0 / fwdLast : 0.0);
std::printf(" bwd ||dL/dx_0|| = %.3e ||dL/dx_%d|| = %.3e ratio %.2e\n\n",
n0, T - 1, nL, nL > 0 ? n0 / nL : 0.0);
if(b == SHIPPED)
{
shippedFwd = fwdLast > 0 ? fwd0 / fwdLast : 0.0;
shippedBwd = nL > 0 ? n0 / nL : 0.0;
}
}
CPU_Shutdown(h);
bool ok = (shippedFwd >= MIN_REACH && shippedBwd >= MIN_REACH);
std::printf("shipped forget-gate bias %+.1f: fwd reach %.3f, bwd reach %.3f (need >= %.2f) -> %s\n",
SHIPPED, shippedFwd, shippedBwd, MIN_REACH, ok ? "PASS" : "FAIL");
if(!ok)
std::printf(" The recurrence cannot see across its own window. Expect a flat IS error and\n"
" OOS recall Neutral:100%%. Check LSTM_FORGET_BIAS_INIT and the cell-state update.\n");
return ok ? 0 : 1;
}