Skip to content

Compare model runs, and say what a comparison could have detected - #3

Merged
chross22 merged 1 commit into
masterfrom
power-analysis
Aug 10, 2026
Merged

Compare model runs, and say what a comparison could have detected#3
chross22 merged 1 commit into
masterfrom
power-analysis

Conversation

@chross22

Copy link
Copy Markdown
Owner

Two functions for comparing model runs, per the scope agreed: the test and the power curve, pairing on shared stations.

compare_runs() — is the gap real?

Refits nothing. Every run already stores its held-out predictions with the station index and the fold, so the per-fold metrics are there to be paired; this adds only the comparison.

compare_runs(list(rf = rf, gam = gam, weak = weak))
#>   comparison reference_score comparison_score difference   lower   upper
#> 1        gam           0.854            0.892     0.0384  0.0141  0.0627
#> 2       weak           0.854            0.654    -0.1997 -0.2964 -0.1030
#>   p_value detectable n_stations
#> 1 0.01177     0.0325        684
#> 2 0.00458     0.1294        684

The test is corrected_paired_test() — the same Nadeau–Bengio correction the jackknife uses, for the same reason — but two-sided, since asking which of two models is better has no direction the way leaving a covariate out does.

detectable is the column that earns its place. It is the smallest true difference the comparison would have found at 80% power, given the fold-to-fold variance it actually saw. "Not significant" on its own conflates these models perform alike with this survey could not tell them apart. It is deliberately not observed power, which is a deterministic function of the p-value and carries no new information (Hoenig & Heisey 2001).

power_curve() — what would it take?

power_curve(dat, list(full = config, starved = starved),
            fractions = c(0.25, 0.5, 1), replicates = 3)
#>   fraction n_stations replicates difference std_err df power detectable
#> 1     0.25        171          3     -0.316  0.1169  4 0.428      0.434
#> 2     0.50        342          3     -0.267  0.0893  4 0.632      0.332
#> 3     1.00        684          3     -0.272  0.0339  4 1.000      0.126

Everything is refitted at every size — a model trained on half the stations is a different model, not the same one evaluated on fewer, and building the curve the cheap way would measure something else. Both runs see the same subsample and the same folds at every point, which keeps the comparison paired the whole way down. It reuses the jackknife's fold_scores(), so it pays for fitting and scoring and nothing else.

Pairing

On the station index, not row order — tune returns folds in its own order and two runs need not agree on it. Runs covering different stations (what different covariate sets produce, through different missingness) are compared on what they share and warn with the count, rather than intersecting silently. There is a test that shuffling one run's rows changes nothing.

Verification

  • 1184 tests passing, 0 failures, run under _R_CHECK_LIMIT_CORES_=TRUE — the variable that broke CI on the last PR.
  • 15 new tests. The behavioural ones are asserted, not eyeballed: a covariate-starved run is found 0.20 of AUC below the full model at p = 0.005; the curve climbs 0.43 → 1.00 as stations grow while detectable falls 0.43 → 0.13; minimum_detectable() and achieved_power() are checked to be inverses.
  • Citation check run locally through the shared distsamp engine, not just assumed: the new Hoenig & Heisey DOI is registered in tools/citations.csv and all five sections pass.

Documented limits

Stated in the docs and the README rather than left for a reader to find: the curve's target difference defaults to the observed one, so a gap that is itself mostly noise yields a curve about a size that may not be real; and these are differences in a bounded metric, so the normal-theory interval behind detectable degrades as AUC approaches 1.

corrected_paired_test() gains an alternative argument, defaulting to the one-sided behaviour the jackknife already relied on.

🤖 Generated with Claude Code

…to see it

Two runs come back with two numbers - ROC AUC 0.854 against 0.892 - and
nothing in either says whether that is a difference between the models or a
difference between the stations the survey happened to visit. Two functions,
answering that and the question that should follow it.

compare_runs() takes fitted runs and refits nothing. Every run already stores
its held-out predictions with the station index and the fold, so the per-fold
metrics are already there to be paired; all this adds is the comparison. The
test is corrected_paired_test(), the same Nadeau-Bengio correction the
covariate jackknife uses and for the same reason - two cross-validation
training sets share most of their rows, so folds are not independent - but
two-sided, since asking which of two models is better has no direction the way
leaving a covariate out does.

The column worth having is `detectable`: the smallest true difference the
comparison would have found, at 80% power, given the fold-to-fold variance it
actually saw. "Not significant" on its own conflates "these models perform
alike" with "this survey could not tell them apart", and that column is what
separates them. It is deliberately not observed power, which is a
deterministic function of the p-value and tells a reader nothing new
(Hoenig and Heisey 2001).

Runs are paired on the station index rather than on row order, because tune
returns folds in its own order and two runs need not agree on it. Runs that
cover different stations - which is what different covariate sets produce, via
different missingness - are compared on what they share, and warn with the
count rather than intersecting silently.

power_curve() answers the other question by refitting at several subsample
sizes and tracing power against n. Everything is refitted at every size: a
model trained on half the stations is a different model, not the same model
evaluated on fewer, and a curve built the cheap way would measure the wrong
thing. Both runs see the same subsample and the same folds at every point,
which is what keeps the comparison paired the whole way down. It goes through
the jackknife's fold_scores(), so it pays for fitting and scoring and for
nothing else - no bootstrap intervals, no importance, no projection.

Both were checked against a run deliberately starved of its ocean covariates,
which compare_runs() puts 0.20 of AUC below the full model at p = 0.005, and
whose power curve climbs from 0.43 at a quarter of the stations to 1.00 at all
of them while the detectable difference falls from 0.43 to 0.13. Those are the
directions the functions exist to produce, and they are asserted rather than
eyeballed.

Two limits are documented rather than hidden. The curve's target difference
defaults to the one observed on the full data, so a gap that is itself mostly
noise gives a curve about a size that may not be real. And these are
differences in a bounded metric, so the normal-theory interval behind
`detectable` degrades as AUC approaches 1.

corrected_paired_test() gains an `alternative` argument, defaulting to the
one-sided behaviour the jackknife already relied on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chross22
chross22 merged commit 51a50c7 into master Aug 10, 2026
6 checks passed
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