Warrior_EA/Expert/AIBase/Inference.mqh

285 lines
15 KiB
MQL5
Raw Permalink Normal View History

feat(ai): logit-adjusted loss, replacing oversampling and the post-hoc prior Menon et al. 2021 (ICLR), "Long-tail learning via logit adjustment": add tau*log(prior_c) to each class logit inside the training gradient. Softmax CE on adjusted logits is consistent for BALANCED error - the metric checkpoint selection already ranks on - so the loss and the deploy decision finally optimize the same thing. The engine already computed a true softmax + categorical-CE gradient and wrote it over the per-neuron sigmoid delta, so this is an offset added to three logits in the two places that gradient is built (backProp scalar path and backPropOCL). No backend, kernel or DLL change; the forward pass and every inference path are untouched, which is the point - the network learns to absorb the offset, so its raw argmax becomes the balanced-optimal decision with nothing applied at inference. Replaces rather than stacks. Minority replay is disabled while this is on, and the post-hoc inference prior is forced off. Stacking is not a theoretical worry: simulated on the measured 1118/1119/34298 distribution in the weak-signal regime, plain CE collapses to Neutral (33.4% balanced, Buy 0%), replay reaches 48.1%, logit adjustment 50.9% with better balance - and BOTH together score 45.4% with Neutral recall at 0%, worse than either alone. Buda et al. 2018 predicts exactly that. Motivation from the six-chart run: every topology took one direction to ~50% recall and abandoned the other, the direction chosen arbitrarily (the batch-norm control went Buy 1% / Sell 42%, the inverse of the other five). One era in 1,301 cleared the per-class recall floor. Fingerprinted conditionally, so the converged 60.7% models on disk keep their filenames and stay loadable as the fallback. Both builds compile 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:05:14 -04:00
//+------------------------------------------------------------------+
refactor: split CExpertSignalAIBase implementation by responsibility ExpertSignalAIBase.mqh was 8216 lines: the class declaration followed by 87 method bodies covering training, labelling, feature extraction, persistence, chart drawing, online learning, the GA auto-tuner and inference, all in one file. Train() alone is 1492 lines; a change to arrow drawing meant scrolling past the era loop. Moved the bodies into Expert\AIBase\, included at the bottom of the original after the class declaration: Training.mqh 1607 era loop, plateau ladder, checkpoint select, deploy Features.mqh 1093 indicator creation + per-bar input feature vector ChartUI.mqh 634 arrows, arrow persistence, status panel, cleanup Persistence.mqh 492 .stats/.cfg sidecars, CPU-inference validation, copy OnlineLearning.mqh 461 live continual learning, EMA shadow, OOS simulator Labels.mqh 309 ZigZag pivot labels, async label-cache prebuild AutoTune.mqh 275 genetic tuner (population, crossover, halving) Inference.mqh 235 softmax, prior calibration, class priors ExpertSignalAIBase.mqh 8216 -> 3131 (declaration + topology build only) This is a pure relocation - verified mechanically, not by eye: HEAD's file reconstructed from the eight partials plus the surviving remainder is byte-identical to HEAD, span for span (scratchpad verify_split.py). No declaration moved, no signature changed, no code rewritten, so behaviour is unchanged by construction. Compiles 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:42:45 -04:00
//| Warrior_EA |
//| AnimateDread |
//| |
//| Read-time signal production: softmax, prior calibration, class p|
//| |
//| PARTIAL IMPLEMENTATION FILE - not standalone. |
//| This holds CExpertSignalAIBase method BODIES only. The class |
//| declaration lives in Expert\ExpertSignalAIBase.mqh, which |
//| #includes this file at the bottom, after the declaration. Do not |
//| include it anywhere else and do not compile it on its own. |
//| |
//| Split out purely to make the 8216-line original navigable; the |
//| code inside was moved verbatim, not rewritten. |
//+------------------------------------------------------------------+
#ifndef WARRIOR_AIBASE_INFERENCE_MQH
#define WARRIOR_AIBASE_INFERENCE_MQH
//+------------------------------------------------------------------+
//| Post-convergence "new bar" handler - see ScheduleTrainingIfNeeded()|
//| for why this exists: once m_trainingComplete is true, a plain new |
//| bar must NOT re-enter Train()'s full era loop (which resets the |
//| best-checkpoint/eta-decay tracking and runs real Net.backProp() |
//| passes again, silently perturbing an already-converged model |
//| forever, once per bar, with no way to ever actually finish). This |
//| only refreshes the price/indicator buffers and re-runs inference |
//| for the newest bar so dPrevSignal/the chart arrow stay current - |
//| identical cost to what Train() does per-bar, minus every bit of |
//| training (label caching, oversampling, backProp, checkpointing). |
//+------------------------------------------------------------------+
void CExpertSignalAIBase::RefreshConvergedSignal(void)
{
int barsNow = (int)MathMin(Bars(m_symbol.Name(), PERIOD_CURRENT, dtStudied, TimeCurrent()) + m_historyBars, Bars(m_symbol.Name(), PERIOD_CURRENT));
if(!ResizeBuffers(barsNow) || !RefreshData())
return;
RefreshLatestSignal();
//--- Continual learning: on a LIVE chart (never the tester/optimizer - OnlineLearnStep() self-guards
//--- on m_inferenceOnly) a deployed model keeps adapting to newly-confirmed structure. Runs AFTER the
//--- live signal is drawn (so the arrow uses the shadow as it was for THIS bar's decision) and BEFORE
//--- dtStudied advances (OnlineLearnStep keeps its own time watermark, independent of dtStudied).
OnlineLearnStep();
dtStudied = m_Time.GetData(0);
}
//+------------------------------------------------------------------+
//| |
//+------------------------------------------------------------------+
void CExpertSignalAIBase::RefreshLatestSignal(void)
{
int i = 0;
TempData.Clear();
TempData.Reserve((int)m_historyBars * m_neuronsCount);
//--- Window ends AT (includes) bar i - see Train()'s matching r declaration comment for why: this
//--- must be the same window Train() learned from, or the deployed model is being queried on a task
//--- it was never trained for.
int r = i;
for(int b = 0; b < (int)m_historyBars; b++)
{
int bar_t = r + b;
if(!BufferTempData(bar_t))
return;
}
if(TempData.Total() < (int)m_historyBars * m_neuronsCount)
return;
//--- Live trading/inference reads from the EMA shadow net, not Net directly - see m_shadowNet's
//--- declaration comment. Falls back to Net if the shadow isn't bootstrapped yet (should only be
//--- momentarily, on a genuinely fresh start before EnsureShadowNet() has run).
EnsureShadowNet();
CNet *deployNet = (CheckPointer(m_shadowNet) != POINTER_INVALID) ? m_shadowNet : Net;
deployNet.feedForward(TempData);
deployNet.getResults(TempData);
if(m_outputNeuronsCount == 1)
dPrevSignal = TempData[0];
else
if(m_outputNeuronsCount == 3)
{
//--- Live decision: softmax first (fills TempData with the raw probs), then prior-correct it so
//--- the deployed EA fires at the calibrated true-base-rate, not the oversampled-uniform rate the
//--- raw argmax over-calls at. This is what makes live trading behave like the panel's live-fired
//--- precision metric (which scores this same rule) - see AdjustedSignalFromSoftmax().
double rawSignal = ApplyClassificationSoftmax();
dPrevSignal = AdjustedSignalFromSoftmax();
if(m_inferenceOnly && DoubleToSignal(rawSignal) != Neutral && DoubleToSignal(dPrevSignal) == Neutral)
Print(__FUNCTION__ + ": " + ID + " - raw softmax " + DoubleToString(rawSignal, 4) +
" was neutralized by prior correction on " + TimeToString(m_Time.GetData(i)) +
" | raw probs B=" + DoubleToString(TempData.At(0), 4) +
" S=" + DoubleToString(TempData.At(1), 4) +
" N=" + DoubleToString(TempData.At(2), 4) +
" | priors B=" + DoubleToString(m_priorBuy, 4) +
" S=" + DoubleToString(m_priorSell, 4) +
" N=" + DoubleToString(m_priorNeutral, 4) +
" | tau=" + DoubleToString(m_logitPriorStrength, 2));
}
datetime bt = m_Time.GetData(i);
//--- Keep a pure inference-side watermark of the newest bar this model has already evaluated. The
//--- tester may load dtStudied from a live-chart save whose timestamp is AHEAD of the simulated
//--- backtest date range; using that training watermark to decide whether a "new bar" exists then
//--- freezes dPrevSignal at its init-bar value for the whole run. m_lastBarTime is this runtime's own
//--- latest evaluated bar instead, so it stays aligned to whichever history the current process is
//--- actually traversing.
m_lastBarTime = bt;
//--- Live NMS: suppress this newest-bar arrow if a same-direction signal was already kept within
//--- m_signalClusterWindow bars - the live equivalent of PruneDirectionalClusters' historical sweep,
//--- so the forward chart declusters the same way the trained history does (see m_signalClusterWindow).
ENUM_SIGNAL lsig = DoubleToSignal(dPrevSignal);
if(lsig != Neutral && NmsLiveAccept(bt, lsig, MathAbs(dPrevSignal)))
DrawObject(bt, dPrevSignal, m_High.GetData(i), m_Low.GetData(i));
else
DeleteObject(bt);
}
//+------------------------------------------------------------------+
//| |
//+------------------------------------------------------------------+
double CExpertSignalAIBase::ApplyClassificationSoftmax(void)
{
// CLASS_LOGIT_SCALE (AI\Network.mqh) must match the training-gradient softmax in
// backProp/backPropOCL exactly - this is the same normalization the loss was trained against.
double maxLogit = CLASS_LOGIT_SCALE * MathMax(TempData.At(0), MathMax(TempData.At(1), TempData.At(2)));
double sum = 0;
for(int res = 0; res < 3; res++)
{
double temp = exp(CLASS_LOGIT_SCALE * TempData.At(res) - maxLogit);
sum += temp;
TempData.Update(res, temp);
}
for(int res = 0; res < 3; res++)
TempData.Update(res, TempData.At(res) / sum);
double pBuy = TempData.At(0);
double pSell = TempData.At(1);
double pNeutral = TempData.At(2);
// TempData.Maximum(0,3) scans left-to-right and keeps the FIRST index on a tie, so any tie
// (including the degenerate all-equal 0.3333/0.3333/0.3333 case from a collapsed/untrained net)
// always resolved to Buy (index 0) - silently turning "the model has no idea" into a directional
// trade. Buy/Sell now only win with a strict majority over BOTH other classes; every tie,
// 2-way or 3-way, falls through to Neutral.
if(pBuy > pSell && pBuy > pNeutral)
return pBuy; // Buy signal
if(pSell > pBuy && pSell > pNeutral)
return -pSell; // Sell signal
return 0; // Neutral signal (also the fallback on any tie)
}
//+------------------------------------------------------------------+
//| Post-hoc logit adjustment (prior correction) of the 3-class |
//| decision. Reads the raw softmax probabilities ApplyClassification|
//| Softmax() left in TempData[0..2] and returns the prior-corrected |
//| signed decision (+P'(buy)/-P'(sell)/0-neutral), the exact rule |
//| live trading fires on and the live-fired precision metric scores. |
//| |
//| The network trains on class-balance-OVERSAMPLED data, so its |
//| softmax estimates P(y|x) under a ~uniform prior and structurally |
//| over-calls the rare Buy/Sell classes. Re-weighting each class by |
//| its TRUE base rate recovers the true-prior posterior (Saerens et |
//| al. 2002 prior correction; Menon et al. 2021 logit adjustment, |
//| ICLR): p'_y ∝ p_y · prior_y^tau . tau (m_logitPriorStrength) |
//| scales it: 0 = raw (identical to ApplyClassificationSoftmax), 1 = |
//| full calibration to the true base rate. Renormalised so the |
//| returned magnitude is a genuine probability the confidence floor |
//| and ConfidenceTier() can read directly. |
//+------------------------------------------------------------------+
double CExpertSignalAIBase::AdjustedSignalFromSoftmax(void)
{
if(TempData.Total() < 3)
return 0.0;
double pBuy = TempData.At(0), pSell = TempData.At(1), pNeutral = TempData.At(2);
//--- Disabled (tau<=0) or priors not measured yet (era 0, or a model with no saved .stats): behave
//--- exactly like the raw argmax so this feature is a strict no-op in that case.
feat(ai): logit-adjusted loss, replacing oversampling and the post-hoc prior Menon et al. 2021 (ICLR), "Long-tail learning via logit adjustment": add tau*log(prior_c) to each class logit inside the training gradient. Softmax CE on adjusted logits is consistent for BALANCED error - the metric checkpoint selection already ranks on - so the loss and the deploy decision finally optimize the same thing. The engine already computed a true softmax + categorical-CE gradient and wrote it over the per-neuron sigmoid delta, so this is an offset added to three logits in the two places that gradient is built (backProp scalar path and backPropOCL). No backend, kernel or DLL change; the forward pass and every inference path are untouched, which is the point - the network learns to absorb the offset, so its raw argmax becomes the balanced-optimal decision with nothing applied at inference. Replaces rather than stacks. Minority replay is disabled while this is on, and the post-hoc inference prior is forced off. Stacking is not a theoretical worry: simulated on the measured 1118/1119/34298 distribution in the weak-signal regime, plain CE collapses to Neutral (33.4% balanced, Buy 0%), replay reaches 48.1%, logit adjustment 50.9% with better balance - and BOTH together score 45.4% with Neutral recall at 0%, worse than either alone. Buda et al. 2018 predicts exactly that. Motivation from the six-chart run: every topology took one direction to ~50% recall and abandoned the other, the direction chosen arbitrarily (the batch-norm control went Buy 1% / Sell 42%, the inverse of the other five). One era in 1,301 cleared the per-class recall floor. Fingerprinted conditionally, so the converged 60.7% models on disk keep their filenames and stay loadable as the fallback. Both builds compile 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:05:14 -04:00
//--- m_useLogitAdjustedLoss: the prior is already baked into the weights by the training
//--- gradient, so applying it again here would correct for the same base rate twice and
//--- push the decision back toward Neutral - undoing exactly what the loss just bought.
if(m_useLogitAdjustedLoss || m_logitPriorStrength <= 0.0 || m_priorBuy <= 0.0 || m_priorSell <= 0.0 || m_priorNeutral <= 0.0)
refactor: split CExpertSignalAIBase implementation by responsibility ExpertSignalAIBase.mqh was 8216 lines: the class declaration followed by 87 method bodies covering training, labelling, feature extraction, persistence, chart drawing, online learning, the GA auto-tuner and inference, all in one file. Train() alone is 1492 lines; a change to arrow drawing meant scrolling past the era loop. Moved the bodies into Expert\AIBase\, included at the bottom of the original after the class declaration: Training.mqh 1607 era loop, plateau ladder, checkpoint select, deploy Features.mqh 1093 indicator creation + per-bar input feature vector ChartUI.mqh 634 arrows, arrow persistence, status panel, cleanup Persistence.mqh 492 .stats/.cfg sidecars, CPU-inference validation, copy OnlineLearning.mqh 461 live continual learning, EMA shadow, OOS simulator Labels.mqh 309 ZigZag pivot labels, async label-cache prebuild AutoTune.mqh 275 genetic tuner (population, crossover, halving) Inference.mqh 235 softmax, prior calibration, class priors ExpertSignalAIBase.mqh 8216 -> 3131 (declaration + topology build only) This is a pure relocation - verified mechanically, not by eye: HEAD's file reconstructed from the eight partials plus the surviving remainder is byte-identical to HEAD, span for span (scratchpad verify_split.py). No declaration moved, no signature changed, no code rewritten, so behaviour is unchanged by construction. Compiles 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:42:45 -04:00
{
if(pBuy > pSell && pBuy > pNeutral)
return pBuy;
if(pSell > pBuy && pSell > pNeutral)
return -pSell;
return 0.0;
}
//--- Work in log space (softmax outputs are strictly > 0, so log is finite): adj_y = log p_y +
//--- tau·log prior_y, then re-softmax to a proper distribution. Max-subtracted for numerical safety.
double aBuy = MathLog(pBuy) + m_logitPriorStrength * MathLog(m_priorBuy);
double aSell = MathLog(pSell) + m_logitPriorStrength * MathLog(m_priorSell);
double aNeutral = MathLog(pNeutral) + m_logitPriorStrength * MathLog(m_priorNeutral);
double mx = MathMax(aBuy, MathMax(aSell, aNeutral));
double eBuy = MathExp(aBuy - mx), eSell = MathExp(aSell - mx), eNeutral = MathExp(aNeutral - mx);
double z = eBuy + eSell + eNeutral;
if(z <= 0.0)
return 0.0;
double qBuy = eBuy / z, qSell = eSell / z, qNeutral = eNeutral / z;
//--- Same strict-majority / ties-to-Neutral rule as ApplyClassificationSoftmax().
if(qBuy > qSell && qBuy > qNeutral)
return qBuy;
if(qSell > qBuy && qSell > qNeutral)
return -qSell;
return 0.0;
}
//+------------------------------------------------------------------+
//| EMA-updates the persisted true class base rates from a finished |
//| era's true class counts. First real measurement seeds directly; |
//| thereafter blended with the same smoothing as the accuracy/ |
//| confidence EMAs so one noisy era can't swing the live decision. |
//+------------------------------------------------------------------+
void CExpertSignalAIBase::UpdateClassPriors(long buyCnt, long sellCnt, long neutralCnt)
{
long tot = buyCnt + sellCnt + neutralCnt;
if(tot <= 0)
return;
double pb = (double)buyCnt / tot, ps = (double)sellCnt / tot, pn = (double)neutralCnt / tot;
if(m_priorNeutral <= 0.0) // first real measurement
{
m_priorBuy = pb;
m_priorSell = ps;
m_priorNeutral = pn;
return;
}
if(m_useStaticPrior || m_freezePriorCalibration)
return;
double k = Net.recentAverageSmoothingFactor;
if(k < 1.0)
k = 1.0;
m_priorBuy += (pb - m_priorBuy) / k;
m_priorSell += (ps - m_priorSell) / k;
m_priorNeutral += (pn - m_priorNeutral) / k;
}
//+------------------------------------------------------------------+
feat(ai): logit-adjusted loss, replacing oversampling and the post-hoc prior Menon et al. 2021 (ICLR), "Long-tail learning via logit adjustment": add tau*log(prior_c) to each class logit inside the training gradient. Softmax CE on adjusted logits is consistent for BALANCED error - the metric checkpoint selection already ranks on - so the loss and the deploy decision finally optimize the same thing. The engine already computed a true softmax + categorical-CE gradient and wrote it over the per-neuron sigmoid delta, so this is an offset added to three logits in the two places that gradient is built (backProp scalar path and backPropOCL). No backend, kernel or DLL change; the forward pass and every inference path are untouched, which is the point - the network learns to absorb the offset, so its raw argmax becomes the balanced-optimal decision with nothing applied at inference. Replaces rather than stacks. Minority replay is disabled while this is on, and the post-hoc inference prior is forced off. Stacking is not a theoretical worry: simulated on the measured 1118/1119/34298 distribution in the weak-signal regime, plain CE collapses to Neutral (33.4% balanced, Buy 0%), replay reaches 48.1%, logit adjustment 50.9% with better balance - and BOTH together score 45.4% with Neutral recall at 0%, worse than either alone. Buda et al. 2018 predicts exactly that. Motivation from the six-chart run: every topology took one direction to ~50% recall and abandoned the other, the direction chosen arbitrarily (the batch-norm control went Buy 1% / Sell 42%, the inverse of the other five). One era in 1,301 cleared the per-class recall floor. Fingerprinted conditionally, so the converged 60.7% models on disk keep their filenames and stay loadable as the fallback. Both builds compile 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:05:14 -04:00
//| Installs the training-time logit offsets - see the declaration. |
//+------------------------------------------------------------------+
void CExpertSignalAIBase::ApplyLogitAdjustment(void)
{
if(CheckPointer(Net) == POINTER_INVALID)
return;
if(!m_useLogitAdjustedLoss || m_logitAdjustTau <= 0.0)
{
//--- Clear rather than merely skip: the input can be turned off on a chart that already installed
//--- offsets this session, and a stale adjustment would keep biasing the gradient silently.
Net.ClearLogitAdjustment();
return;
}
//--- Priors not measured yet (era 0 before the first tally, or a model with no .stats): leave the
//--- gradient unadjusted rather than guessing a distribution. The next era installs them.
if(m_priorBuy <= 0.0 || m_priorSell <= 0.0 || m_priorNeutral <= 0.0)
{
Net.ClearLogitAdjustment();
return;
}
//--- ORDERED to match the output layer: [0]=Buy, [1]=Sell, [2]=Neutral - the order
//--- BuildFreshTopology emits and the order the softmax gradient reads (AI\Network.mqh).
double offsets[3];
offsets[0] = m_logitAdjustTau * MathLog(m_priorBuy);
offsets[1] = m_logitAdjustTau * MathLog(m_priorSell);
offsets[2] = m_logitAdjustTau * MathLog(m_priorNeutral);
Net.SetLogitAdjustment(offsets);
}
//+------------------------------------------------------------------+
refactor: split CExpertSignalAIBase implementation by responsibility ExpertSignalAIBase.mqh was 8216 lines: the class declaration followed by 87 method bodies covering training, labelling, feature extraction, persistence, chart drawing, online learning, the GA auto-tuner and inference, all in one file. Train() alone is 1492 lines; a change to arrow drawing meant scrolling past the era loop. Moved the bodies into Expert\AIBase\, included at the bottom of the original after the class declaration: Training.mqh 1607 era loop, plateau ladder, checkpoint select, deploy Features.mqh 1093 indicator creation + per-bar input feature vector ChartUI.mqh 634 arrows, arrow persistence, status panel, cleanup Persistence.mqh 492 .stats/.cfg sidecars, CPU-inference validation, copy OnlineLearning.mqh 461 live continual learning, EMA shadow, OOS simulator Labels.mqh 309 ZigZag pivot labels, async label-cache prebuild AutoTune.mqh 275 genetic tuner (population, crossover, halving) Inference.mqh 235 softmax, prior calibration, class priors ExpertSignalAIBase.mqh 8216 -> 3131 (declaration + topology build only) This is a pure relocation - verified mechanically, not by eye: HEAD's file reconstructed from the eight partials plus the surviving remainder is byte-identical to HEAD, span for span (scratchpad verify_split.py). No declaration moved, no signature changed, no code rewritten, so behaviour is unchanged by construction. Compiles 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:42:45 -04:00
//| Converts a double to ENUM_SIGNAL. |
//| 3-output (softmax classification) case: dPrevSignal's *sign* |
//| alone already encodes the argmax-selected class (+prob for Buy, |
//| -prob for Sell, exactly 0.0 for Neutral - see Train()/ |
//| RefreshLatestSignal()), so classification here is pure argmax: |
//| whichever class the network actually picked, full stop. No |
//| magnitude threshold is applied - confidence magnitude is a |
//| separate concern, already exposed via AIConfidence()/ |
//| SignedAIConfidence() (MathAbs(dPrevSignal)/dPrevSignal) for the |
//| signal engine's own confidence-weighted filters/lot sizing/SLTP, |
//| so this keeps "which class" and "how confident" decoupled. |
//| 1-output (tanh regression) case: unrelated network shape, keeps |
//| the original 0.50 magnitude cutoff as a genuine confidence gate. |
//+------------------------------------------------------------------+
ENUM_SIGNAL CExpertSignalAIBase::DoubleToSignal(double value)
{
value = NormalizeDouble(value, 2); // Round 'value' to two decimal places
if(value < -1.0 || value > 1.0)
return Undefine; // out of range, e.g. the -2 "not yet studied" sentinel
if(m_outputNeuronsCount == 3)
{
if(value > 0.0)
return Buy;
if(value < 0.0)
return Sell;
return Neutral;
}
if(value > 0.50)
return Buy;
if(value < -0.50)
return Sell;
return Neutral;
}
#endif // WARRIOR_AIBASE_INFERENCE_MQH