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>
136 lines
5.7 KiB
C++
136 lines
5.7 KiB
C++
// 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;
|
|
}
|