Skip to content

feat!: model averaging, out-of-fold cross-validation with per-fold stopping; faster quantile scale pass - #165

Merged
brndnmtthws merged 8 commits into
mainfrom
feat/averaging-oof-cv
Oct 20, 2026
Merged

brndnmtthws merged 8 commits into
mainfrom
feat/averaging-oof-cv

Conversation

@brndnmtthws

@brndnmtthws brndnmtthws commented Oct 20, 2026 •

Copy link
Copy Markdown
Owner

Summary

Three slutbot-driven items from the training workstream:

  1. perf(objective): the quantile/absolute-error residual-scale pass forms its square-root terms 8 rows at a time and sums the total weight in the same pass as output 0; every f64 sum still adds in row order from -0.0. The quantile intercept's 4M-label comparison sort becomes data::sort::stable_order, a stable radix sort (-0/+0 one key). Bit for bit unchanged.
  2. BoostedModel::average (Python Booster.average): native model averaging. Every leaf (vector leaves and linear-leaf coefficients included) is divided by the member count, trees are concatenated, and intercepts are averaged in margin space. Members must agree on objective (with its parameters), max_delta_step, features, outputs, targets, classes, num_parallel_tree and leaf kind; gblinear, model shrinkage, non-unit tree weights (DART), Boulevard, EBM and best_iteration are refused (IncompatibleModel { what: "average" }). For log links the average is the geometric mean of the members' predictions, for multiclass the softmax of the mean logits (documented).
  3. Cross-validation out-of-fold margins and per-fold early stopping (breaking): CrossValidation::early_stopping(CvStopping) replaces early_stopping_rounds, with CvStopping::fold_mean(patience) (the existing mode) and CvStopping::per_fold(patience, PurgedTail). In per-fold mode each fold early-stops on a purged chronological tail of its own training rows, then retrains on all of them for the chosen rounds. run() returns a CvReport whose folds carry each fold's test rows, round count, scores, margins and tail split; CvReport::out_of_fold() assembles per-row OOF margins. CrossValidation::history_only() reports the fold-mean history alone, which cv and Python's hessboost.cv run. Python: hessboost.cross_validate(..., per_fold_stopping=folds.PurgedTail(...), refit=) returning CvReport (CvReportRefit for the refit); hessboost.cv keeps its XGBoost-shaped results.

PurgedTail cuts the tail by time span, as slutbot's early_stopping_split does (the first decision time at or after first + (1 − fraction)·(last − first)), and purges with purged_forward's inclusive rule.

Version: the CV API change is breaking, so both manifests go to 0.4.0 (crates.io's latest release is 0.3.0; main also carries the unreleased feat(python)! of #162).

Measurements (/home/admin/hbbench, 4M × 173 panel, Neoverse-V3)

Residual-scale pass, per call:

case before after
weighted, one output 5.7 ms 3.5 ms
three outputs, unweighted 7.5 ms 7.0 ms
three outputs, weighted 13 ms 9.5 ms
one output, unweighted unchanged unchanged

End to end (prof.py 8 41, five alternating runs, min / median per round; the shifts are within run-to-run noise, squared error moved as much without being touched):

before after
quantile 84.3 / 86.2 ms 86.9 / 88.3 ms
quantile, weighted 85.5 / 88.9 ms 86.6 / 87.5 ms
squared (untouched) 65.0 / 67.3 ms 67.0 / 68.2 ms

The intercept sort shows in the first round: quantile 3.18 → 2.95 s, weighted 3.58 → 3.35 s.

Bit identity: ws5_bitident.py hashes model JSON and margins of quantile (one and three alphas) and absolute error (one and two targets), weighted with zero weights and unweighted, at 1/4/16 threads: all 24 lines equal before and after.

average against slutbot's average_models (3 seeds × 30 rounds, every 40th panel row):

head leaves differing margins differing
reg:quantileerror 233 of 1440 (all 1 ulp) 0 of 100k
reg:tweedie 216 of 1438 (all 1 ulp) 2799 of 100k (max 2.98e-7)

Every native leaf is the correctly rounded v/k; average_models scales the shortest-decimal JSON leaves, which parse to f64 values other than the stored f32 (all 1435 checked), so its products miss by one ulp. Intercepts agree exactly.

Review fixes

  • history_only: routing hessboost.cv through the report path kept every fold's model until the best fold-mean round was known and, for DART/EBM/Boulevard under early stopping, retrained every fold for margins it then discarded. History-only runs keep nothing past each fold's scores (refused with per-fold stopping, which has no fold-mean history). hessboost.cv with DART (20k × 20, 5 folds, 150 rounds; same process, early_stopping_rounds=10 vs none): 1.85 s vs 1.26 s (1.47×) before, 1.48 s vs 1.52 s (0.97×) after.
  • Without early stopping, a fold's model is dropped once its margins are taken (Codex's finding).
  • PurgedTail::split compares offsets as integers against the cutoff rounded up to a whole tick, capped at the span: past 2^53 ticks (datetime64[ns] times over ~104 days) an f64 offset one tick before the cutoff rounded onto it and joined the tail ([0, t − 1, t, 2t], t = 2^54, fraction 0.5 split at t − 1). Unchanged below 2^53.
  • Python: zero-round cross-validation raised ValueError building fold scores (hessboost.cv(params, dtrain, 0) included); PurgedTail.split raised OverflowError for negative rows instead of HessboostError.
  • out_of_fold refuses an edited report (public fields) instead of indexing out of bounds.
  • Docs: EBM round counts span both stages, each capped at num_boost_round (so not "at most num_boost_round"); the EBM bag-stopping refusal no longer names the removed early_stopping_rounds builder; Booster.average refuses DART only for non-unit tree weights.

Checks

cargo fmt --check (root, python, fuzz, cuda-kernels), clippy --all-targets --features wgpu -D warnings (root) and --all-targets -D warnings (python), cargo nextest run --cargo-profile ci --features wgpu (828 passed), doctests, RUSTDOCFLAGS=-D warnings cargo doc --features wgpu, uv run --locked pytest (440 passed), ruff, ty, pyright --verifytypes (100%), cargo semver-checks --features wgpu (0.3.0 → 0.4.0: major, passes).

…intercept

residual_scales (reg:quantileerror, reg:absoluteerror) forms its square-root
terms eight rows at a time so they vectorize and sums the total weight in the
same pass as output 0's terms; every f64 sum still adds in row order from
-0.0, so scales are bit-identical. The quantile intercept's stable label
order comes from a stable radix sort on canonical (+0) keys instead of a
comparison sort. Models and margins are bit-identical to before.
… stopping

BoostedModel::average (Python Booster.average) combines compatible members
into one model predicting their mean margin: trees concatenated with every
leaf the f32 nearest v/k, intercepts averaged in f64; incompatible members
(objective, features, layout, DART weights, shrinkage, gblinear, Boulevard,
EBM, early stopping) are refused with IncompatibleModel.

CrossValidation::run now returns a CvReport with each fold's round count,
scores, and test margins, and CvReport::out_of_fold combines them per row.
CrossValidation::early_stopping(CvStopping) replaces early_stopping_rounds:
fold-mean stopping as before, or per-fold stopping on a PurgedTail of each
fold's own training rows, refitting each fold on all its rows. Python adds
hessboost.cross_validate, CvReport, FoldReport, OutOfFold, TailStopping,
and folds.PurgedTail; hessboost.cv keeps its XGBoost-shaped results.

BREAKING CHANGE: CrossValidation::run returns CvReport (results in
CvReport::results), CvRefit::results is CvRefit::report, and
CrossValidation::early_stopping_rounds is early_stopping(CvStopping::fold_mean).
- average: compare leaf kinds against the first member with trees, and
  validate the result's structure as a last guard (returning the error).
- cv: refuse EBM bag stopping under every CvStopping; refuse per-fold
  stopping for Boulevard fits up front; name tail-split refusals
  `purged tail` everywhere.
- PurgedTail: Fold::purged_forward's single-block convention (fraction of
  distinct decision times, split at a decision time); length mismatches
  (also purged_forward's) are DimensionMismatch.
- Python: cross_validate's CvReport.refit is a Refit without the history;
  CvRefit is cv()'s record again; Booster.average raises TypeError for
  non-Boosters; PurgedTail.split refuses non-integer rows.
PurgedTail starts the tail at the first decision time at or after
first + (1 - fraction) * (last - first), as slutbot's early_stopping_split
does, instead of by the count of distinct decision times (purged_forward's
rule), so an irregular gap no longer shifts the tail. split_at stays a
decision time and the purge stays purged_forward's (inclusive).
…hon Refit

Check that DART folds stopped early on the fold means report the margins
of the fold retrained for the chosen rounds, and that per-fold stopping
with target statistics reports the hand-built trial's best score. Rename
the Python hessboost.Refit to CvReportRefit and reword the README's
per-fold stopping paragraph.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-20T16:13:45.536930Z 8330bc2 Manual request
🔒 Security Review ✅ Completed 2026-10-20T16:14:25.359193Z 8330bc2 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

CvReport's fields are public, so out_of_fold checks each fold's margin
rows against its test rows, every fold's width against fold 0's, and the
test rows' bounds before indexing by them.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c11cf3fb78

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/training/cv/mod.rs Outdated
- CrossValidation::history_only reports the fold-mean history alone;
  cv and Python's hessboost.cv run it, so they keep no fold model past
  its run and replay no DART, EBM or Boulevard fold for margins they
  discard (hessboost.cv with DART and early stopping took about 1.5x as
  long). Refused with per-fold stopping, which has no fold-mean history.
- Without early stopping, a fold's model is dropped once its margins
  are taken instead of every fold's being kept until the last trains.
- PurgedTail::split compares decision-time offsets as integers against
  the cutoff rounded up to a whole tick and capped at the span: past
  2^53 ticks (nanosecond times over months) an f64 offset one tick
  before the cutoff rounded onto it and moved into the tail.
- Python: cross-validation of zero rounds builds empty fold scores
  instead of raising ValueError (hessboost.cv(params, dtrain, 0) did);
  PurgedTail.split refuses negative rows with HessboostError, not
  OverflowError.
- Docs: a fold's EBM round count counts both stages, each capped at
  num_boost_round; the EBM bag-stopping refusal no longer names the
  removed early_stopping_rounds builder; Booster.average refuses DART
  models with non-unit tree weights, and max_delta_step mismatches.
@brndnmtthws

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep them coming!

Reviewed commit: 8330bc2364

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: 8330bc2364

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant