diff options
| author | Scott Gasch <[email protected]> | 2026-10-25 09:55:30 -0700 |
|---|---|---|
| committer | Scott Gasch <[email protected]> | 2026-10-25 09:55:30 -0700 |
| commit | 874ef0478078ef4eb436af7d2b68a353f61f4d70 (patch) | |
| tree | e9954c289393c64d88402beff04637b52732ca73 /src/fhmodel.c | |
| parent | 5f901d737b17c5f99b6e456d6c0c7d5e3c093dc4 (diff) | |
Learned FH% logistic-regression model (alpha full, beta whole-node-only)
Replaces the data.c histogram tables (g_uJointFHThousandths/
g_uBetaFHThousandths and their _NNUE counterparts) with two quantized
logistic regressions as the default FH%-prediction mechanism under
FH_USE_REGRESSION_MODEL=1, trained against real collected search data
(fhcollect.c) per docs/REGRESSION.md.
Alpha side is a full per-move regression, validated against the old
table (0.119pp weighted mean deviation across 10,684 matched cells).
Beta side is whole-node-only (matching the old table's own scope --
no functionality lost), refit on the NO_MOVE population after an
earlier mixed-tier fit saturated and over-pruned; its skip threshold
(FH_BETA_SKIP_THRESHOLD=99000) was confirmed by direct self-play
sweeps, not just ECM solve-count intuition (see docs/REGRESSION.md's
"Category A vs B" discussion).
Gated by an overnight SPRT match (regression vs HEAD tables, same
NNUE eval, sd8): at 4120 games, llr=+2.53 (never dipped negative,
peaked +2.8), score=0.517, candidate reaching comparable depth at
higher nps/ebf than head. Did not cross the H1=+2.94 SPRT bound, but
the trend across the full run is unambiguous and the marginal value of
waiting out the remaining games was judged not worth it.
The data.c tables are left in place as the inactive #else branch of
FH_USE_REGRESSION_MODEL for now; a follow-up commit will flip the
build default and remove them once this commit's head_reference
baseline and ECM numbers are recorded.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'src/fhmodel.c')
| -rw-r--r-- | src/fhmodel.c | 201 |
1 files changed, 201 insertions, 0 deletions
diff --git a/src/fhmodel.c b/src/fhmodel.c new file mode 100644 index 0000000..b3935bc --- /dev/null +++ b/src/fhmodel.c @@ -0,0 +1,201 @@ +// fhmodel.c -- production inference for the FH% logistic-regression +// models. See fhmodel.h and docs/REGRESSION.md. +// +// Fully quantized: every runtime computation here is integer +// fixed-point (Q16 for table/final-sum arithmetic, Q30 for the +// handful of raw-feature weights -- see fhmodel_weights.h's header +// comment for why two scales). No floats, no log()/exp() calls. +#include "chess.h" +#include "fhmodel_weights.h" + +// Mate-score window gate -- see fhmodel_weights.h's +// FHM_MATE_GAP_THRESHOLD (matches load_records.py's +// MATE_GAP_THRESHOLD exactly) and docs/REGRESSION.md's mate-window +// note. A `gap` this large means the static eval is itself encoding +// a detected mate score compared against an ordinary cp-scale +// window -- a structurally different regime this model was never +// trained on (those rows were excluded entirely, not just clipped) +// and must not extrapolate into. + +static INLINE INT +_FhmLog1pLookup(const INT *table, ULONG uMaxIndex, ULONG uValue) +{ + ULONG uClamped = (uValue > uMaxIndex) ? uMaxIndex : uValue; + return(table[uClamped]); +} + +// Converts a Q16 fixed-point logit into the sigmoid*100000 return +// convention, via table lookup only (no exp() call). Linearly maps +// the clamped logit into the table's index range. +static ULONG +_FhmLogitQ16ToThousandths(INT iLogitQ16) +{ + INT64 iRangeQ16 = (INT64)(FHM_SIGMOID_LOGIT_RANGE * (1 << FHM_Q_TABLE)); + INT64 iClamped = iLogitQ16; + INT64 iIndex; + + if (iClamped < -iRangeQ16) iClamped = -iRangeQ16; + if (iClamped > iRangeQ16) iClamped = iRangeQ16; + + // index = (clamped + range) / (2*range) * (TABLE_SIZE - 1) + iIndex = ((iClamped + iRangeQ16) * (FHM_SIGMOID_TABLE_SIZE - 1)) / + (2 * iRangeQ16); + if (iIndex < 0) iIndex = 0; + if (iIndex >= FHM_SIGMOID_TABLE_SIZE) iIndex = FHM_SIGMOID_TABLE_SIZE - 1; + + return((ULONG)g_rgiSigmoidThousandthsQ16[iIndex]); +} + +// Shared node-level logit (Q16), common to both tables' identical +// 7-feature node-level set. iGap is the caller's own "how far past +// the relevant edge" quantity (always >= 0 by construction -- see +// callers), already confirmed <= FHM_MATE_GAP_THRESHOLD by the +// caller's own gate before this is ever reached. +static INT +_FhmNodeLogitQ16(const INT *rgiLogGap, const INT *rgiLogGapOverflow, + const INT *rgiLogDepth, const INT *rgiLogMaterial, + const INT *rgiNodeTypeWeight, + INT iWImprove, INT iWEnemyImprove, INT iWImproveXEnemy, + ULONG uGapCap, SCORE iGap, ULONG uDepth, + SCORE iImprovement, SCORE iEnemyImprovement, + ULONG uTotalNonPawnMaterial, NODE_TYPE eNodeTypeGuess) +{ + ULONG uGapNonNeg = (iGap > 0) ? (ULONG)iGap : 0; + ULONG uGapClipped = (uGapNonNeg < uGapCap) ? uGapNonNeg : uGapCap; + ULONG uGapOverflow = (uGapNonNeg > uGapCap) ? (uGapNonNeg - uGapCap) : 0; + ULONG uDepthPlies = uDepth / ONE_PLY; + INT64 iSumQ16 = 0; + + iSumQ16 += _FhmLog1pLookup(rgiLogGap, uGapCap, uGapClipped); + iSumQ16 += _FhmLog1pLookup(rgiLogGapOverflow, + FHM_MATE_GAP_THRESHOLD - uGapCap, uGapOverflow); + iSumQ16 += _FhmLog1pLookup(rgiLogDepth, FHM_DEPTH_PLIES_MAX, uDepthPlies); + iSumQ16 += _FhmLog1pLookup(rgiLogMaterial, FHM_MATERIAL_MAX, uTotalNonPawnMaterial); + iSumQ16 += rgiNodeTypeWeight[(ULONG)eNodeTypeGuess]; + + // Raw (non-tabulated) terms: Q30 weight * raw integer value, + // rescaled down to the shared Q16 accumulation frame. + iSumQ16 += ((INT64)iWImprove * (INT64)iImprovement) >> FHM_RAW_SHIFT; + iSumQ16 += ((INT64)iWEnemyImprove * (INT64)iEnemyImprovement) >> FHM_RAW_SHIFT; + iSumQ16 += ((INT64)iWImproveXEnemy * + ((INT64)iImprovement * (INT64)iEnemyImprovement)) >> FHM_RAW_SHIFT; + + return((INT)iSumQ16); +} + +FHM_NODE_BASE +FhModelAlphaNodeBaseQ16(IN SCORE iEval, + IN SCORE iAlpha, + IN ULONG uDepth, + IN SCORE iImprovement, + IN SCORE iEnemyImprovement, + IN ULONG uTotalNonPawnMaterial, + IN NODE_TYPE eNodeTypeGuess) +{ + SCORE iGap = (SCORE)(iAlpha - iEval); + INT iLogitQ16; + + if (iGap > FHM_MATE_GAP_THRESHOLD) + { + return((FHM_NODE_BASE)FHM_MATE_GATED); + } + iLogitQ16 = _FhmNodeLogitQ16( + g_rgiAlphaLogGapQ16, g_rgiAlphaLogGapOverflowQ16, + g_rgiAlphaLogDepthQ16, g_rgiAlphaLogMaterialQ16, + g_rgiAlphaNodeTypeWeightQ16, + FHM_ALPHA_W_IMPROVE_Q30, FHM_ALPHA_W_ENEMY_IMPROVE_Q30, + FHM_ALPHA_W_IMPROVE_X_ENEMY_Q30, + FHM_GAP_CAP_ALPHA, iGap, uDepth, iImprovement, iEnemyImprovement, + uTotalNonPawnMaterial, eNodeTypeGuess); + iLogitQ16 += FHM_ALPHA_INTERCEPT_Q16; + return((FHM_NODE_BASE)iLogitQ16); +} + +ULONG +FhModelAlphaMoveThousandths(IN FHM_NODE_BASE nodeBase, + IN SCORE iMoveValue, + IN ULONG uMoveOrdinal, + IN ULONG uMoveFhPctThousandths, + IN ULONG uMoveFhAttempts, + IN FH_COLLECT_TIER eTier) +{ + INT64 iSumQ16 = (INT64)(INT)nodeBase; + + iSumQ16 += _FhmLog1pLookup(g_rgiAlphaLogOrdinalQ16, FHM_ORDINAL_MAX, uMoveOrdinal); + iSumQ16 += _FhmLog1pLookup(g_rgiAlphaLogFhAttemptsQ16, FHM_FH_ATTEMPTS_MAX, uMoveFhAttempts); + iSumQ16 += g_rgiAlphaTierWeightQ16[(ULONG)eTier]; + iSumQ16 += ((INT64)FHM_ALPHA_W_MOVE_VALUE_Q30 * (INT64)iMoveValue) >> FHM_RAW_SHIFT; + iSumQ16 += ((INT64)FHM_ALPHA_W_MOVE_FH_PCT_Q30 * (INT64)uMoveFhPctThousandths) >> FHM_RAW_SHIFT; + + return(_FhmLogitQ16ToThousandths((INT)iSumQ16)); +} + +// Whole-node-only -- see fhmodel.h. Move-level terms fixed at exactly +// the defaults a FHC_TIER_NO_MOVE training row had (fhcollect.c's +// FhCollectNodeExit: iMoveValue=0, uMoveOrdinal=0, +// uMoveFhPctThousandths=0, uMoveFhAttempts=0) -- log1p(0)==0 and +// 0*weight==0 for all of them, so this collapses to the node logit + +// tier weight + intercept, but computed via the same lookup path as +// the alpha function for consistency. +ULONG +FhModelBetaPredictedFHThousandths(IN SCORE iEval, + IN SCORE iBeta, + IN ULONG uDepth, + IN SCORE iImprovement, + IN SCORE iEnemyImprovement, + IN ULONG uTotalNonPawnMaterial, + IN NODE_TYPE eNodeTypeGuess) +{ + SCORE iGap = (SCORE)(iEval - iBeta); + INT64 iSumQ16; + + if (iGap > FHM_MATE_GAP_THRESHOLD) + { + return(MOVE_FH_ESTIMATE_NO_DATA); + } + iSumQ16 = _FhmNodeLogitQ16( + g_rgiBetaLogGapQ16, g_rgiBetaLogGapOverflowQ16, + g_rgiBetaLogDepthQ16, g_rgiBetaLogMaterialQ16, + g_rgiBetaNodeTypeWeightQ16, + FHM_BETA_W_IMPROVE_Q30, FHM_BETA_W_ENEMY_IMPROVE_Q30, + FHM_BETA_W_IMPROVE_X_ENEMY_Q30, + FHM_GAP_CAP_BETA, iGap, uDepth, iImprovement, iEnemyImprovement, + uTotalNonPawnMaterial, eNodeTypeGuess); + iSumQ16 += g_rgiBetaTierWeightQ16[(ULONG)FHC_TIER_NO_MOVE]; + iSumQ16 += FHM_BETA_INTERCEPT_Q16; + + return(_FhmLogitQ16ToThousandths((INT)iSumQ16)); +} + +// See fhmodel.h's comment: this engine's own convention for "is this +// the hash move" is x==0 (confirmed by grep -- there's no separately +// stored mvHash compared against mv anywhere in search.c's move +// loop), so this does NOT reuse FhClassifyMoveTier's mv-vs-mvHash +// comparison (which would need a real mvHash, not available here) -- +// it inlines the same capture/killer/countermove decode logic that +// function uses for everything past the hash check. +FH_COLLECT_TIER +FhModelTierForMove(IN ULONG x, IN MOVE mv, IN SCORE iMoveSortValue) +{ + FLAG fIsCaptureOrPromotion; + + if (0 == x) + { + return(FHC_TIER_HASH); + } + fIsCaptureOrPromotion = IS_CAPTURE_OR_PROMOTION(mv); + if (0 != fIsCaptureOrPromotion) + { + return((iMoveSortValue >= SORT_THESE_FIRST) ? + FHC_TIER_GOOD_CAPTURE : FHC_TIER_LOSING_CAPTURE); + } + if (0 != (iMoveSortValue & (FIRST_KILLER | SECOND_KILLER | THIRD_KILLER | FOURTH_KILLER))) + { + return(FHC_TIER_KILLER); + } + if (COUNTERMOVE_TIER_BONUS == (iMoveSortValue & COUNTERMOVE_TIER_BONUS)) + { + return(FHC_TIER_COUNTERMOVE); + } + return(FHC_TIER_LEFTOVER_QUIET); +} |
