fix: training-stability audit fixes F1/F2/F3/F5 - unbiased shuffle, real plateau escapes, fresh optimizer state on restore, pure OOS metric
Four of the six findings from research/training_pipeline_audit_2026-08-09.md
(F4 mini-batching and F6 feature re-encode deliberately deferred - see the
report's implementation-status section for why):
- F1: pass-2 Fisher-Yates (and AutoTune's MI block shuffle) used MathRand()%,
which is 15-bit - provably non-uniform on every full-history era over 32,768
queued samples. New 30-bit ShuffleRandomIndex().
- F2: plateau warm restarts were a no-op whenever eta already sat at its
ceiling (the normal state of a non-regressing plateau) - the ladder was just
a 24-era countdown. Restarts now overshoot to 5x the ceiling
(PLATEAU_RESTART_BOOST) and anneal geometrically back over the patience
window, SGDR-style; ETA_MIN widened 1e-4 -> 1e-5 so the decay schedule has
real range.
- F3: checkpoint restores put weights back but kept the rejected trajectory's
Adam moments, so the optimizer immediately pushed back toward the rolled-back
state (the restore->regress->restore oscillation). CNet::ResetOptimizerState()
zeroes moments/momentum/step counters (weights, BN statistics, gamma/beta
untouched) on every mid-run restore, every boosted restart, and the
deploy-time restore that online learning continues from.
- F5: batch-norm running statistics now freeze for the pass-3 OOS scoring walk,
so the selection metric the checkpoint ranking and deploy gate read is a pure
function of the checkpoint instead of partly measuring BN drift. Defensive
unfreeze in FinalizeTrainRun covers stop-mid-pass; live/online adaptation and
the OOS continual-learning simulation stay adaptive by design.
Compiled clean (0 errors, 0 warnings) via the staged-tree recipe.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 10:54:09 -04:00
# Training pipeline audit — plateau escape, stability, normalization, capacity
Date: 2026-08-09. Scope: everything between the feature vector and the deployed checkpoint —
input normalization, derived topology, optimizer/learning-rate mechanics, the era loop's
correction mechanisms (checkpoint restore, eta decay, plateau ladder, shadow EMA), and the
selection/deploy gates. Every claim below carries a file:line reference; nothing is quoted
from memory.
---
## 0. Verdict in one paragraph
The pipeline is structurally sound and unusually well-defended (purged OOS split, logit-adjusted
loss, family-wise deploy gate, checkpoint restore on regression). The instability you see between
eras has three real, fixable contributors: * * (1)** training is pure batch-size-1 online Adam, so
per-era weight trajectories are intrinsically noisy; * * (2)** the learning-rate machinery operates
inside a 3x total dynamic range (1e-4 … 3e-4), so the plateau ladder's "warm restart" is a no-op
whenever eta is already at its ceiling — which is exactly the state a non-regressing plateau is in;
**(3)** the pass-2 shuffle uses MQL5's 15-bit `MathRand()` , which makes the Fisher-Yates provably
non-uniform on any era with more than 32,768 queued samples (every full-history H1 run). Fixing
these will make training * smoother and more monotone * . What they will not do is make OOS accuracy
climb "better and better": the repo's own measurements (noise-floor MI verdict, lag-profile verdict,
26 classic-pattern models at chance on 178k bars) say the directional information in these features
is at or below the noise floor. A neural net does not find patterns where there is no mutual
information; it finds patterns where nobody * looked * , which is a different thing. The honest
formulation of your goal is: IS error should fall monotonically (it can), OOS should rise to the
data's information ceiling and hold there stably (it can), and the family-wise gate decides whether
that ceiling is tradeable (so far it has said no).
---
## 1. Direct answers to your questions
### "We use Adam as the main optimizer and switch to SGD for fine-tuning — right?"
**No.** There is exactly one optimizer per model, chosen by the `TrainingOptimizer` input
(default `ADAM` , [Variables/Inputs.mqh:210 ](../Variables/Inputs.mqh#L210 )), and it runs for the
whole life of the model. An Adam→SGD switch * used to exist * ("replay-only optimizer override")
and was removed on 2026-07-28 for good reason: it forced the entire net to SGD during pass 2,
and pass 2 is the **only ** place `backProp()` runs — so "Adam with SGD fine-tuning" was in fact
"SGD at Adam's learning rate, always", with Adam's moments never updated. The removal note is at
[Training.mqh:680-689 ](../Expert/AIBase/Training.mqh#L680-L689 ). Do not reintroduce a switch;
the literature on optimizer switching (SWATS) shows mixed results, and this codebase has already
been burned once by a half-switched optimizer.
### "Should we play with learning rate and momentum when we keep regressing?"
The pipeline already adjusts the learning rate on regression — but inside a range so narrow the
adjustments barely do anything. Current mechanics:
| Mechanism | Trigger | Effect | Where |
|---|---|---|---|
| eta decay | selection score drops >5pp below best (`ETA_DECAY_REGRESSION_PCT` ) | best checkpoint restored, `eta *= 0.7` , floor 1e-4 | [Training.mqh:1545-1554 ](../Expert/AIBase/Training.mqh#L1545-L1554 ) |
| recovery bump | new best era | `eta /= 0.7` , cap at ceiling | [Training.mqh:1482 ](../Expert/AIBase/Training.mqh#L1482 ) |
| warm restart | 8 / 16 eras with no new best (plateau stages 1, 2) | `eta = m_etaCeiling` | [Training.mqh:1588-1602 ](../Expert/AIBase/Training.mqh#L1588-L1602 ) |
| terminal | 24 eras with no new best (stage 3) | deploy best checkpoint, stop | [Training.mqh:1604-1662 ](../Expert/AIBase/Training.mqh#L1604-L1662 ) |
Constants: ceiling = `AdamLearningRate` = 3e-4 ([AI/Network.mqh:68 ](../AI/Network.mqh#L68 )),
`ETA_MIN` = 1e-4, `ETA_DECAY_FACTOR` = 0.7
([ExpertSignalAIBase.mqh:295-297 ](../Expert/ExpertSignalAIBase.mqh#L295-L297 )).
**Finding F2 (high impact): the plateau ladder's only escape is frequently a no-op.**
eta starts at the ceiling and only ever * leaves * the ceiling via the regression-decay branch.
A run that plateaus * without * a >5pp regression therefore sits at eta == ceiling, and both
"warm restarts" assign the value eta already has. In that state the ladder is not an escape
mechanism at all — it is a 24-era countdown to deploy. Even in the best case (eta floored at
1e-4), the restart is a 3x kick; SGDR-style restarts that actually relocate a model typically
span 10-100x. Additionally, escaping a basin generally requires a rate * larger * than the one
that settled into it — restoring the original rate revisits the same basin.
**Momentum:** `AdamBeta1` = 0.9 / `AdamBeta2` = 0.999, book defaults
([AI/Network.mqh:68-77 ](../AI/Network.mqh#L68-L77 )); bias correction is implemented correctly
per-neuron (`lt = eta*sqrt(1-b2^t)/(1-b1^t)` ,
[NeuronOCLBase.mqh:564,613 ](../AI/Impl/NeuronOCLBase.mqh#L613 )). Hand-tuning beta1 was already
tried (0.8 experiment) and correctly reverted. The momentum problem is not the constants — see F3.
**Finding F3 (medium): checkpoint restore keeps stale Adam moments.**
`CaptureWeights()/RestoreWeights()` snapshot weights only — documented deliberately at
[AI/Network.mqh:686-689 ](../AI/Network.mqh#L686-L689 ). After a restore, the very next updates are
driven by first/second moments accumulated * along the rejected trajectory * , i.e. the optimizer
immediately pushes the restored weights back toward the state that just got rolled back. The delta
clip and decayed eta bound the damage but don't remove the direction. This is a plausible direct
cause of the "restore → regress again → restore again" oscillation you're watching. Zeroing the
moment buffers (and the per-neuron `t` counters) on restore — and on plateau warm restarts — is
cheap and is standard practice in Adam-with-restarts setups.
### "Do we have enough neurons and layers?"
The shape is * derived from the data * , not configured, and the derivation is deliberate about being
small: the first dense layer gets at most **one weight per in-sample bar **
([Topology.mqh:809-841 ](../Expert/AIBase/Topology.mqh#L809-L841 )), snapped down a
{16,32,64,…,1024} ladder; depth comes from a geometric taper to the output
([Topology.mqh:669-689 ](../Expert/AIBase/Topology.mqh#L669-L689 )); conv filters = half the
receptive-field window snapped to {4..32}; LSTM hidden solves `4H(H+in+1) ≤ isBars`
([Topology.mqh:771-805 ](../Expert/AIBase/Topology.mqh#L771-L805 )).
Two honest observations:
- **Capacity is not why training plateaus.** More neurons buy IS fit. The OOS ceiling is set by
how much label-relevant information the features carry, and the project's own measurements put
that at/near the noise floor for direction ([[project_lag_profile_verdict]],
[[project_mi_noise_floor_verdict]], [[project_classic_patterns_no_edge]]). A bigger net finds
the noise faster.
- **Finding F6 (low-medium): with the full default feature set the first layer is pinned at its
16-unit floor.** ~60+ features/bar × 20 bars ≈ 1,200+ inputs; at ~25-30k in-sample bars the
budget `isBars/(inputWidth+1)` lands near or below 16, so the derived width bottoms out and the
warning at [Topology.mqh:833-839 ](../Expert/AIBase/Topology.mqh#L833-L839 ) may already be firing.
Check the startup `config -` line. If it reads "N dense from 16 units", the honest options are
(a) prune feature groups the research track has already measured as edge-free, which widens the
budget per input, or (b) accept the pinch — * not * raise the budget: 1 param/sample is already
generous for this signal-to-noise regime.
---
## 2. Bug found
**Finding F1: biased Fisher-Yates — `MathRand()` is 15-bit.**
[Training.mqh:699 ](../Expert/AIBase/Training.mqh#L699 ): `int sJ = MathRand() % (sIdx + 1);`
MQL5's `MathRand()` returns 0..32767. For every `sIdx ≥ 32768` the modulo is the identity, so
`sJ` is uniform on [0, 32767] only: a slot in the upper region can never swap with another upper
slot, and each upper element gets exactly one forced exchange with the lower region. For
`sIdx` just under 32768 there is ordinary modulo bias. The queue holds one entry per IS bar
(every bar queued exactly once, [Training.mqh:616-625 ](../Expert/AIBase/Training.mqh#L616-L625 )),
and the training window is * all available history * — an H1 symbol easily exceeds 32,768 IS bars,
so the shipped configuration hits this every era. The shuffle exists precisely to break the
correlated same-class gradient runs documented at the `MAX_WEIGHT_DELTA` comment
([AI/Network.mqh:350-356 ](../AI/Network.mqh#L350-L356 )); a non-uniform shuffle partially
re-admits them, era after era, in the same pattern (the bias is deterministic in structure).
**Fix (small):** compose a 30-bit value — `int r30 = (MathRand() << 15) | MathRand();` then
`sJ = r30 % (sIdx + 1);` (or rejection-sample to kill the residual modulo bias — at 30 bits
against ~100k the residual is negligible). Same fix applies to
[AutoTune.mqh:455 ](../Expert/AIBase/AutoTune.mqh#L455 ) if its array can exceed 32k (check).
---
## 3. Why eras "visually regress" — the full causal list
1. **Batch-size-1 online Adam (F4, the big one). ** One weight update per sample
([NeuronBatchNorm.mqh:60-63 ](../AI/NeuronBatchNorm.mqh#L60-L63 ) states it plainly: "pure online
SGD - one weight update per sample, never a batched pass"). Gradient noise at batch 1 is maximal;
the end-of-era weight state is a random variable with substantial variance, so consecutive eras
genuinely differ even with identical data. Every defense in the file (delta clip, weight decay,
shadow EMA, checkpoint restore) is compensating for this at the symptom level.
**Literature-backed fix: gradient accumulation into mini-batches of 16-64. ** Noise scales
~1/sqrt(B); B=32 cuts update variance ~5-6x, smooths the loss trajectory, makes Adam's second
moment estimate meaningful, and typically * speeds up * wall-clock convergence (fewer, better
steps). This is the single highest-impact stability change available. Implementation: accumulate
output-layer gradients across B samples before calling the update kernels — the shuffle,
logit-adjust and sample-weight machinery are all unaffected.
2. **Stale Adam moments after checkpoint restore (F3, above). **
3. **No-op warm restarts (F2, above) ** — the ladder never actually perturbs a stuck run, so a run
oscillates in the 5pp dead zone (no restore, no decay: [Training.mqh:1452 ](../Expert/AIBase/Training.mqh#L1452 ))
until the timer expires.
4. **Batch-norm statistics move on every forward pass, including OOS scoring (F5). ** Documented as
deliberate ([NeuronBatchNorm.mqh:70-79 ](../AI/NeuronBatchNorm.mqh#L70-L79 )): pass 3 scoring
advances the EMA mean/variance, so (a) the OOS number partly measures BN drift rather than the
trained function, and (b) the same weights score differently depending on what was scored
before them. `SetBatchNormFrozen` already exists and is already used by
`ValidateCpuInference` ([Persistence.mqh:243-246 ](../Expert/AIBase/Persistence.mqh#L243-L246 ))
for exactly this reason. **Fix: freeze BN stats for the duration of pass 3 ** (and the OOS
simulation walk), unfreeze after. This changes measurement only, not live behavior, and makes
the selection metric a pure function of the checkpoint — which is what a metric that decides
deployment should be.
5. **Part of the "regression" is measurement noise, by design. ** The per-era selection score at
~11k directional calls has ~0.4pp standard error, more at low-coverage eras; the 5pp
dead zone deliberately ignores oscillation inside it; and `dOosForecast` is a 10,000-sample EMA
([NetBuild.mqh:16 ](../AI/Impl/NetBuild.mqh#L16 )) that carries momentum across eras. Two eras
that "look" different on the chart (NMS arrows redraw per era) can be statistically identical.
---
## 4. Normalization audit — result: sound, two representation nits
- **Price-unit features:** ATR-normalized at source, with the rationale spelled out at
[Features.mqh:477-488 ](../Expert/AIBase/Features.mqh#L477-L488 ). Correct and cross-symbol stable.
Bars with no ATR or EMPTY_VALUE are rejected, not zero-filled.
- **Clamps:** every block clamps to single digits (±10 swing, ±5 volume, ±2 delta); one
whole-bar sanitize gate zero-fills anything non-finite or >1e4
([Features.mqh:921-937 ](../Expert/AIBase/Features.mqh#L921-L937 )) — this is the fix for the
2026-08-02 BN NaN-latch and it is correctly placed. BN has its own 1e6 input bound and a
1e-4 std floor ([NeuronBatchNorm.mqh:103-109 ](../AI/NeuronBatchNorm.mqh#L103-L109 )).
- **Layer-level:** batch norm ON by default (window 1000), placed between dense pairs and before
the head ([Topology.mqh:1072-1134 ](../Expert/AIBase/Topology.mqh#L1072-L1134 )), which absorbs
the remaining cross-family scale differences. EMA-form stats with cold-start bias ramp — correct
for an online regime.
- **Nit N1: `EventCode` (and `StructuralPhase` ) are categorical codes fed as ordinals**
([Features.mqh:886-890 ](../Expert/AIBase/Features.mqh#L886-L890 )). The network is told event 9
is "three times" event 3. A first dense layer can partially untangle this, but it wastes
capacity the 16-wide entrance doesn't have. Options: one-hot the code (costs ~10-20 inputs —
conflicts with F6), or collapse to the 2-3 axes that matter (event fired y/n, direction ±1,
phase 1-5 as its own scaled scalar). Low urgency; do it with the next forced retrain, since it
changes the input contract (fingerprint `WIN` -style version bump).
- **Nit N2:** heterogeneous natural ranges remain (RSI/100 in [0,1] vs ±10 clamps vs raw Wyckoff
readings "with no natural range", [Features.mqh:922-923 ](../Expert/AIBase/Features.mqh#L922-L923 )).
With BN on this is mostly harmless; without BN it would matter. Fine as is * because * BN defaults on —
note the coupling if BN is ever disabled for an experiment.
---
## 5. Loss / head / labels — audited, no defects found
Joint softmax+CCE gradient over sigmoid-bounded outputs with `CLASS_LOGIT_SCALE` 6.0 temperature
([NetForward.mqh:387-401 ](../AI/Impl/NetForward.mqh#L387-L401 ),
[AI/Network.mqh:366-377 ](../AI/Network.mqh#L366-L377 )); logit adjustment applied backward-only with
per-era re-measured priors ([Training.mqh:340-345 ](../Expert/AIBase/Training.mqh#L340-L345 )); label
smoothing 0.9/0.05/0.05 sums to 1.0; AdamW-style decoupled decay at 1e-3 with the 0.01-failure
history documented; per-step delta clip 0.1; saturation-derivative floor 1e-3. The bounded-head +
temperature design is unconventional but internally consistent, and the comments correctly forbid
unbinding the head without retuning the two constants calibrated against it
([Topology.mqh:1144-1162 ](../Expert/AIBase/Topology.mqh#L1144-L1162 )). Leave this stack alone.
---
## 6. Prioritized action plan
| # | Change | Effort | Expected effect | Risk |
|---|---|---|---|---|
| 1 | **F1 ** 30-bit shuffle RNG ([Training.mqh:699 ](../Expert/AIBase/Training.mqh#L699 )) | trivial | removes a deterministic bias present in every full-history era | none |
| 2 | **F4 ** mini-batch gradient accumulation (B≈32) in `backProp` | medium | largest stability win; smoother, likely faster convergence | moderate (touches update path; verify dW/W in situ per [[feedback_verify_in_situ_not_offline]]) |
| 3 | **F3 ** zero Adam moments + `t` on `RestoreWeights()` and on plateau warm restarts | small | stops restore→re-regress oscillation | low |
| 4 | **F2 ** give restarts amplitude: restart eta to 3-10x ceiling for a bounded number of eras (delta clip already bounds per-step damage), widen `ETA_MIN` to 1e-5; optionally cosine-decay within the cycle (SGDR) | small | turns the ladder into a real escape instead of a timer | low-moderate |
| 5 | **F5 ** freeze BN stats during pass-3 scoring + OOS sim | small | selection metric becomes a pure function of the checkpoint | none (measurement only) |
| 6 | **F6/N1 ** prune measured-dead feature groups; re-encode EventCode | medium | frees entrance capacity; cleaner representation | forces retrain (fingerprint bump) |
Order matters: do 1, 3, 5 together (cheap, independently safe), then 2, then 4 — because 4's effect
can't be evaluated while per-sample noise (2) dominates the era-to-era variance.
### Implementation status (2026-08-09, same day — compiled clean, 0 errors 0 warnings)
- **F1 DONE** — `ShuffleRandomIndex()` (30-bit) added in ExpertSignalAIBase.mqh, used by the pass-2
queue shuffle (Training.mqh) and the MI-null block shuffle (AutoTune.mqh).
- **F3 DONE** — `CNet::ResetOptimizerState()` (NetWeights.mqh) + per-neuron-type overrides
(dense/conv/LSTM/batch-norm, both OCL and legacy scalar hierarchies; `ZeroOptimizerBuffer()` in
BufferDouble.mqh). Called on the mid-run regression restore, on every plateau warm restart, and on
the deploy-time restore in FinalizeTrainRun (online learning continues from that net). Weights,
BN running statistics, and gamma/beta are untouched — optimizer state only.
- **F2 DONE** — `PLATEAU_RESTART_BOOST 5.0` : restarts now jump eta to 5x the ceiling (1.5e-3) with a
geometric anneal back to the ceiling over `PLATEAU_PATIENCE_ERAS` eras (SGDR-style bounded cycle),
cleared early on any new best; `ETA_MIN` widened 1e-4 → 1e-5. Each restart also resets optimizer
moments (F3), so the kick explores instead of replaying the plateau's momentum.
- **F5 DONE** — BN running statistics frozen for the whole pass-3 OOS scoring walk
(`SetBatchNormFrozen(true/false)` ), with a defensive unfreeze in FinalizeTrainRun for the
stop-mid-pass path. The selection metric is now a pure function of the checkpoint. The OOS
continual-learning * simulation * is deliberately NOT frozen — it evaluates live adaptive behaviour,
where the moving statistics are part of what is being simulated.
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
- **F4 DONE** (second pass, same day). True mini-batch accumulation, `TRAIN_BATCH_SIZE = 32` .
Design: the O(n²) outer product is native (two new kernels/exports per backend —
`AccumulateWeightGrad` , `AccumulateWeightGradConv` , plus a generic `AccumulateBufferInto` ), while
the optimizer step is **host-side MQL5, shared by every tier ** (`ApplyAccumToBlock` ), so there is
one Adam/SGD implementation instead of four that can drift. Both DLLs rebuilt. Notes:
- The LSTM needs no outer-product kernel — by the update pass its `WeightsGradient` already holds
the sample's full dW. It could * not * simply be left un-zeroed between samples, because
`CPU_LSTMSeqBackward` /`DML_LSTMSeqBackward` **memset that buffer on entry ** ; hence the separate
accumulator plus elementwise add.
- Batch-norm's gamma/beta accumulate in host arrays, not new `BatchOptions` slots — `BN_OPT_STRIDE`
is baked into every persisted `.nnw` via `getWeightsBN` .
- Scoped to pass 2 only; `OnlineLearnStep` keeps immediate per-sample updates. Every save,
checkpoint and scoring boundary calls `FlushBatch()` , which scales by the * real * sample count so a
short trailing batch still takes a correctly-sized step.
- Degrades, never fails: `CNet::BatchSize()` returns 1 (with a one-time log line) on a tier that
cannot accumulate — an OpenCL device where the kernels didn't build, or the legacy scalar tier a
DLL-free Market build can land on. Those train exactly as before.
- **Verified offline**: `DirectML/batch_accum_check.cpp` (+ `build_accum_check.bat` ) drives the real
exports and compares against an independent reference. All pass, including the load-bearing one —
at B=1 the accumulator equals the gradient the * shipped unbatched kernel * forms internally, to
1.1e-16. That proves the math, **not ** that a layer trains in the assembled net; the in-situ check
is still the per-layer `dW/W` report on a real era.
- **F6 DONE, and the root cause was not what this report first said.** Confirmed from the deployed
`.cfg` files that CONV, LSTM and HYBRID were * all * pinned at the 16-unit floor (64 features/bar ×
20 bars = 1,280 inputs). But the fix is not pruning features: `ComputeFirstLayerWidth()` was
budgeting against the **raw ** input width even on topologies where a conv/LSTM front end has
already reduced it — an LSTM hands the dense stack 64 values, not 1,280, so it was being charged
~20x its real fan-in. Now budgeted against the front-end output, and capped at that width so the
first dense layer can never fan * out * (a shape `FrontEndConfigSummary` already flags as a defect).
`InitNeuralNetwork` reorders the derivation accordingly (conv filters → LSTM hidden → first-layer
width → depth). Expected effect on the shipped config: LSTM/HYBRID 16 → 64 units, CONV 16 → 32/64;
plain MLP unchanged.
- **N1 DONE, with a correction to this report's premise.** Reading the indicator, `EventCode` is
±1..7 where the sign is accumulation/distribution and the magnitude is position in the Wyckoff
schematic (PS→SC→AR→ST→Spring→LPS→SOS) — so it is genuinely * ordinal * , and this report's "event 9
is three times event 3" framing was wrong (there is no event 9). The real defect is that direction
and stage are entangled in one scalar across a sign discontinuity — precisely what the base OHLC
block already fixes by giving direction its own ±1/0 flag. All three signed Wyckoff categoricals
(`EventCode` , `EventPhase` , `StructuralPhase` ) are now split into a direction and a [0,1]-scaled
magnitude. Information-preserving (the pair reconstructs the original exactly); nothing was dropped
— including the collinear-but-nonlinear `StructuralPhase` , kept deliberately so this change is
purely a re-encoding and any effect is attributable to it alone. 13 readings now occupy 16 inputs.
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
## 6b. POST-DEPLOY ADDENDUM — the first run at B=32 regressed, and why
The change set above was deployed and run on SP500 H1 (four charts, 12:49–13:30). It regressed every
topology. The diagnosis found one root cause that predates F4 entirely, plus two ways F4 made it
visible. Recorded here because the root cause invalidates part of the reasoning above.
### What the run showed
| reading | before (`ee48381` ) | after (`0c01dc2` ) |
|---|---|---|
| `lstm1` dW/W, eras 2/3/4 | 2.62% / 10.0% / 7.14% | 0.024% / 0.022% / **0.003% ** |
| `conv1` dW/W, early → late | 0.52% → 1.18% @ era 42 | 0.35% → **0.000% @ era 30 ** |
| PAI IS error | 0.41–0.42 @ era 1028+ | 0.48 flat, converged @ era 41 |
`conv1` /`lstm1` in that report read `getWeightsConv` /`getWeightsLSTM` only, so this is unambiguous:
the convolution kernel and the LSTM recurrent block stopped training. The `bn*` figures stayed
healthy, but they are not a counter-example — their norm is dominated by running variance
(`bn2` norm 263,053 against `var` 2.65e+05) and BN running stats update per * sample * in the forward
pass, where batching cannot reach them.
### R1 — THE ADAM SECOND MOMENT WAS NOT ADAM (all four tiers)
Every Adam kernel in the engine 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) // v_old is a standard deviation, not a variance
```
That recursion has a fixed point at `v ≈ b2 = 0.999` for * any * gradient below unit scale, because
`(1-b2)·g²` becomes negligible against `b2·v` . The denominator therefore stops tracking the gradient
scale, and `delta = lt·m/v` degrades from scale-invariant Adam into plain SGD with `lr = lt` .
Measured against the shipped `WarriorCPU.dll`
([batch_accum_check.cpp ](../DirectML/batch_accum_check.cpp ), `TestOptimizerScaleInvariance` ),
4000 steps of a constant gradient:
| gradient magnitude | stored `v` | displacement | vs magnitude 1 |
|---|---|---|---|
| 1e+0 | 1.000000 | 1.019 | 1x |
| 1e-2 | 0.999000 | 1.050e-2 | 97x less |
| 1e-5 | 0.999000 | 3.101e-4 | **3285x less ** |
A scale-invariant optimizer gives the same displacement in every row. After the fix — square `v`
back before re-entering the recursion, which * is * the textbook second moment, merely carried in
std-dev form so the stored value can be the denominator directly — all six rows read 1.199 and
`v` tracks the gradient magnitude exactly.
**Why this hit the front end specifically.** Conv and LSTM sit * behind * a batch-norm whose running
variance is ~2.6e+05, so their gradients arrive divided by ~500 — deep in the degraded regime — while
the dense stack nearer the loss sees gradients of order 1 and stayed in the working regime. This also
explains a recurring theme in this engine's history: the front-end stage has * always * been the one
that "won't train".
**It was already known, in one place.** `NeuronBatchNorm.mqh` squares the stored value back for
gamma/beta and its comment named the kernels as wrong, but deliberately left them alone because
fixing them "would alter the behaviour of every existing model on all four backends at once". That
is exactly why gamma/beta kept learning while the stages behind them froze. Now reconciled: OpenCL
(`Network.cl` x3), `WarriorCPU.cpp` x3, `WarriorDML.cpp` HLSL x3, the host-side batched step in
`NeuronOCLBase.mqh` , and the legacy scalar tier in `NeuronCPU.mqh` .
The persisted `.nnw` needs no migration — `v` keeps its std-dev meaning, only the recursion changed.
### R2 — mini-batching was never paid for
Two compounding errors in F4, both mine:
- **No learning-rate compensation.** B=32 takes 32x fewer optimizer steps per era. The linear rule
(Goyal et al. 2017) is for SGD; for adaptive methods the rule is `sqrt(B)` (Krizhevsky 2014;
derived for Adam in Granziol et al. 2022). Now applied once, in `InitialEtaForOptimizer()` .
- **Patience denominated in eras.** An era is a data pass, so raising B silently made the plateau
ladder 32x more impatient in the only unit it measures. PAI declared convergence at era 41 on ~49k
updates where the same config had been finding new bests at era 1028. `TrainPlateauPatienceEras()`
now stretches it by the same `sqrt(B)` .
Both are exact identities at B=1. `TRAIN_BATCH_SIZE` is 32 → **8 ** : after the `sqrt(B)` LR bump an era
still makes `sqrt(B)` less progress, so patience must stretch by `sqrt(B)` too — at 32 that is 8 → 45
eras per ladder stage on a topology already taking 70 s/era, at 8 it is 8 → 23. Noise still falls as
`1/sqrt(B)` , so 8 keeps ~2.8x of the variance reduction that was the point.
Note R2 could not have been fixed in terms of B alone while R1 stood: under the degraded optimizer,
averaging B samples shrank the front end's gradient by a further `sqrt(B)` * on top of * the B fewer
steps, and step size was proportional to gradient magnitude.
### R3 — the deployability floor let a one-sided model ship
PAI reported `Sell:0%` recall in all 41 eras, cleared the floor on Buy alone (36.6% against 34%
chance) and deployed — the "buy sprayer" on the chart. `tradeableOK` tested coverage and precision
only; coverage counts directional calls without caring that they are all the same direction. Added
`DEPLOY_MIN_SIDE_RECALL_PCT` (10%), folded into `tradeableOK` so a one-sided era cannot become the
best-so-far in the first place, not merely be refused at the deploy gate. Deliberately far below the
40%-per-class diagnostic, which is unreachable on this data and would block every deployment.
CONV, for contrast, was correctly blocked by the existing floor — the gate works, it was just blind
to one-sidedness.
### R4 — the two chart-cleanup bugs
- **CONV stranded its arrows** because `OnDeinit` was force-terminated: 13:29:58.202 → "Abnormal
termination" 13:30:03.002, 4.8 s against ~1.1 s for the three charts that finished, having reached
none of its cleanup. That is MetaTrader's OnDeinit budget expiring, not a fault. `ExtPanel.Destroy()`
— an unbounded CAppDialog teardown — sat * ahead * of the arrow purge, the same ordering inversion the
rule there exists to prevent. Arrow cleanup now runs first, and each step is timed so the log names
the slow one.
- **`persisted 10 ... cleared 0` ** on LSTM and PAI is unresolved and needs a run to settle: two scans
microseconds apart disagreed about the same chart, and the WARNING branch built to catch exactly
that stayed silent because its rescan filtered on `OBJ_ARROW` in the same way the bulk delete did.
The rescan now walks every object type, and the both-zero case reports the object counts explicitly
instead of passing silently.
### Revised deploy notes
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
### Deploy notes for this change set
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
1. **Both DLLs must be redeployed ** with the `.ex5` — they carry new exports, and now also the
corrected Adam kernels (R1). Verified: both build variants compile 0 errors / 0 warnings. `build_cpu.bat` and
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
`build.bat` copy them into every discovered MetaTrader `MQL5\Libraries` .
2. **N1 forces a retrain ** of every Wyckoff-enabled config: the fingerprint gains `|WES:2|` and
`m_neuronsCount` goes 64 → 67. That is intended — it is also what lets F6's new shape take effect,
since derived widths are adopted from a model's existing `.cfg` rather than re-derived. A config
with the Wyckoff events * off * keeps its fingerprint and its model, and will only pick up F6 after a
manual reset-weights.
3. **First run to watch, in this order ** : the startup `config -` line (first dense layer should no
longer read 16 on CONV/LSTM/HYBRID); then per-era `dW/W` (every stage must move — a stage stuck at
~0.000% means the batched path is starving it); then whether IS error falls more smoothly than
before, which is the whole point of F4.
4. If anything looks wrong, `TRAIN_BATCH_SIZE 1` in `ExpertSignalAIBase.mqh` restores the exact
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
pre-F4 update path without touching anything else — including the `sqrt(B)` LR scale and the
patience stretch, both of which are identities at 1. It does NOT revert R1; the Adam fix is
independent of batching and should stay on either way.
5. **R1 changes training dynamics for every topology, not just the front end. ** The single reading
that confirms it worked is `conv1` /`lstm1` dW/W staying alive (order 0.1–1%) instead of decaying
toward 0.000% — check that before any accuracy number.
fix: training-stability audit fixes F1/F2/F3/F5 - unbiased shuffle, real plateau escapes, fresh optimizer state on restore, pure OOS metric
Four of the six findings from research/training_pipeline_audit_2026-08-09.md
(F4 mini-batching and F6 feature re-encode deliberately deferred - see the
report's implementation-status section for why):
- F1: pass-2 Fisher-Yates (and AutoTune's MI block shuffle) used MathRand()%,
which is 15-bit - provably non-uniform on every full-history era over 32,768
queued samples. New 30-bit ShuffleRandomIndex().
- F2: plateau warm restarts were a no-op whenever eta already sat at its
ceiling (the normal state of a non-regressing plateau) - the ladder was just
a 24-era countdown. Restarts now overshoot to 5x the ceiling
(PLATEAU_RESTART_BOOST) and anneal geometrically back over the patience
window, SGDR-style; ETA_MIN widened 1e-4 -> 1e-5 so the decay schedule has
real range.
- F3: checkpoint restores put weights back but kept the rejected trajectory's
Adam moments, so the optimizer immediately pushed back toward the rolled-back
state (the restore->regress->restore oscillation). CNet::ResetOptimizerState()
zeroes moments/momentum/step counters (weights, BN statistics, gamma/beta
untouched) on every mid-run restore, every boosted restart, and the
deploy-time restore that online learning continues from.
- F5: batch-norm running statistics now freeze for the pass-3 OOS scoring walk,
so the selection metric the checkpoint ranking and deploy gate read is a pure
function of the checkpoint instead of partly measuring BN drift. Defensive
unfreeze in FinalizeTrainRun covers stop-mid-pass; live/online adaptation and
the OOS continual-learning simulation stay adaptive by design.
Compiled clean (0 errors, 0 warnings) via the staged-tree recipe.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 10:54:09 -04:00
A further literature-backed option once the above land: deploy the **shadow EMA ** net (already
maintained at tau 0.01/era, [Training.mqh:1161-1163 ](../Expert/AIBase/Training.mqh#L1161-L1163 ))
as the inference model rather than the raw best checkpoint — that is stochastic weight averaging
in all but name, and it is the standard cure for "the deployed snapshot happened to be a lucky
era". The infrastructure exists; only the deploy wiring would change.
## 7. Expectation setting — what "linear progression" can mean here
Monotone * IS * improvement is achievable (and the fixes above get you close to it). Monotone * OOS *
improvement up to a stable maximum is achievable only up to the information ceiling of the
feature/label pair, and the repo's own instruments — the family-wise selection gate
([Training.mqh:60-78 ](../Expert/AIBase/Training.mqh#L60-L78 )), the MI noise-floor and lag-profile
verdicts — currently place that ceiling at "no deployable directional edge". The correct reading
of a post-fix run: if training becomes smooth, plateaus cleanly, and the gate still says
p_family ≈ 1, then the plateau is * real * and the next lever is the data (features/labels), not
the optimizer. The one measured positive remains excursion * size * (range 4x its null, p=0.005) —
a volatility/risk head, not a direction head. Chasing direction with a better optimizer cannot
overturn a measurement about the data.