Skip to content

feat: cross-validated accuracy for surrogates (fit_surrogate/RegimeSurrogate) - #117

Merged
jc-macdonald merged 4 commits into
mainfrom
feat/114-surrogate-accuracy
Aug 19, 2026
Merged

feat: cross-validated accuracy for surrogates (fit_surrogate/RegimeSurrogate)#117
jc-macdonald merged 4 commits into
mainfrom
feat/114-surrogate-accuracy

Conversation

@jc-macdonald

Copy link
Copy Markdown
Collaborator

Summary

Neither SurrogateModel backend reports how well it actually fits: RF has a free OOB score available but GP doesn't have an equivalent, so consumers currently have no way to distinguish "the surrogate genuinely learned the response surface" from "the surrogate is overfit to noise and its recommend() output is arbitrary" -- exactly the problem #114 describes for VBPCApy's Option A pipeline (fits a RegimeSurrogate on ~1200 single-evaluation rows, queries it for point recommendations across dozens of regimes, with no accuracy check anywhere).

Design

  • fit_surrogate() now computes a uniform k-fold cross-validated R^2/RMSE per observable for both the gp and rf backends (rather than RF-only OOB), so the metric is directly comparable across backends. cv_folds (default 5) is clamped to the observable's available row count; CV is skipped (nan) below 2 folds.
  • A UserWarning fires at fit time (warn_below_r2, default 0.0 -- "no better than predicting the training mean") naming any observable that falls below threshold. Pass None to disable.
  • SurrogateModel.cv_r2 / .cv_rmse: new per-observable dict fields.
  • RegimeSurrogate.cv_r2 / .cv_rmse: passthrough properties (no need to reach into .inner). fit_regime_surrogate() forwards cv_folds/warn_below_r2.
  • RegimeSurrogate.recommend() additionally checks the specific objective being optimized at call time and warns there too (warn_below_r2 param, same default) -- per the issue's ask that this be "surfaced prominently enough that callers can't easily ignore it," a fit-time warning buried in pipeline logs isn't enough on its own.

Test plan

  • just ci green locally (ruff format/lint, mypy --strict, coverage 99.49%, 332 passed -- 25 new)
  • cv_r2/cv_rmse present and finite for both backends on a well-fit deterministic relationship (R^2 > 0.8)
  • cv_folds clamped correctly for small data; cv_folds=1 disables CV (nan, no spurious warning)
  • Fitting to pure noise triggers the default warning; warn_below_r2=None disables it
  • RegimeSurrogate.cv_r2/.cv_rmse match .inner.cv_r2/.inner.cv_rmse exactly
  • recommend() warns on a poorly-fit objective at call time, independent of the fit-time warning; warn_below_r2=None disables it

Neither backend reports how well it actually fits: RF has a free OOB
score but GP doesn't have an equivalent, so fit_surrogate() now
computes a uniform k-fold cross-validated R^2/RMSE per observable for
both backends (RF's OOB is skipped in favor of consistency -- a
directly comparable metric across backends matters more here than the
extra fit cost). cv_folds is clamped to the observable's available row
count; a UserWarning fires (opt-out via warn_below_r2=None) when any
observable's CV R^2 falls below a threshold (default 0.0 -- no better
than predicting the training mean).

Motivating case: VBPCApy's Option A pipeline fits a RegimeSurrogate on
~1200 single-evaluation rows and queries it for point recommendations
across dozens of regimes with no way to tell a genuinely-learned
response surface from one that's overfit to noise.
)

RegimeSurrogate wraps a SurrogateModel (previous commit) but callers
shouldn't need to reach into .inner for the accuracy check -- cv_r2/
cv_rmse are now passthrough properties, and fit_regime_surrogate()
forwards cv_folds/warn_below_r2 to the underlying fit_surrogate() call.

recommend() also checks the specific objective's cv_r2 at call time
and warns there too (opt-out via warn_below_r2=None), so a caller
optimizing against a poorly-fit objective gets a signal right at the
point of use rather than only in fit-time logs that are easy to miss
in a pipeline.

Closes #114
…ores

CI's freshly-resolved numpy (2.5.2, vs the stale 2.4.4 my local env had
pinned) caught a real strict-mypy mismatch local mypy missed:
ResultsTable.scores is NDArray[np.floating[Any]], not float64
specifically, so y[mask] doesn't satisfy a float64-typed parameter.
_cross_val_accuracy never actually needed float64 -- any floating dtype
works for KFold/estimator fit/predict/arithmetic -- so widen the hint
to match what's really passed instead of narrowing the caller.
@jc-macdonald
jc-macdonald merged commit b964a81 into main Aug 19, 2026
4 checks passed
@jc-macdonald
jc-macdonald deleted the feat/114-surrogate-accuracy branch August 19, 2026 14:38
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