Skip to content

Introduce model_compare() for model comparison with support of new predictive measures - #380

Open
florence-bockting wants to merge 78 commits into
pred_measurefrom
integrate-loo_compare
Open

florence-bockting wants to merge 78 commits into
pred_measurefrom
integrate-loo_compare

Conversation

@florence-bockting

@florence-bockting florence-bockting commented Jul 7, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #220

Summary

This PR adds model_compare(). The function compares models on all predictive measures of the *_pred_measure() API (#363).

  • model_compare() replaces loo_compare().
  • loo_compare() is deprecated. It warns once per session. It still compares "loo", "waic", and "kfold" objects on ELPD.
  • loo_compare methods in other packages (e.g. loo_compare.brmsfit) still dispatch.
  • model_compare() accepts results from loo_pred_measure(), kfold_pred_measure(), test_pred_measure(), and
    insample_pred_measure().
  • For each measure that all models share, the function computes the paired difference and its standard error.
  • Each measure uses its own best model as the reference.
  • The function flips loss measures (e.g. mse) to the utility scale. A higher difference is then always better. print() marks each flipped measure.
  • The new custom_measure() defines a custom measure. It sets the name, the orientation, and the standard error of the difference.

The output for "loo", "waic", and "kfold" objects does not change.

The comment below lists all new features and functions.

The review decisions are in notes/design-discussions/model_compare.md.

Examples

@codecov-commenter

codecov-commenter commented Jul 7, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.28978% with 104 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.65%. Comparing base (2a77502) to head (d03261f).
⚠️ Report is 1 commits behind head on pred_measure.

Files with missing lines Patch % Lines
R/model_compare-print.R 86.73% 37 Missing ⚠️
R/model_compare-pred_measure.R 92.70% 28 Missing ⚠️
R/pred_measure-builtin.R 67.85% 27 Missing ⚠️
R/pred_measure-helpers.R 92.30% 5 Missing ⚠️
R/pred_measure-compute.R 95.89% 3 Missing ⚠️
R/loo_compare.R 75.00% 2 Missing ⚠️
R/model_compare.R 99.14% 2 Missing ⚠️
Additional details and impacted files
@@               Coverage Diff                @@
##           pred_measure     #380      +/-   ##
================================================
+ Coverage         91.29%   91.65%   +0.36%     
================================================
  Files                35       38       +3     
  Lines              4168     5044     +876     
================================================
+ Hits               3805     4623     +818     
- Misses              363      421      +58     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

github-actions Bot commented Jul 7, 2026 •

Copy link
Copy Markdown

This is how benchmark results would change (along with a 95% confidence interval in relative change) if d03261f is merged into pred_measure:

  • ✔️loo_function: 1.09s -> 1.09s [-1.44%, +1.47%]
  • ✔️loo_matrix: 1.18s -> 1.17s [-2.51%, +0.62%]
    Further explanation regarding interpretation and methodology can be found in the documentation.

florence-bockting and others added 22 commits July 8, 2026 10:49
…nals

Re-derives the file split on top of the current branch rather than merging
the earlier WIP, which git resolved into eight duplicate definitions.

model_compare.R (2082 lines) is split by concern into model_compare.R (the
generic, default method, ordering and diagnostics), model_compare-pred_measure.R
(the multi-measure path, rank resolution, standard errors) and
model_compare-print.R (print.compare.loo and its table helpers). Every moved
body is byte-identical to its previous version.

loo_compare.R keeps only the forwarding generic and its methods. The copies of
elpd_diffs, se_elpd_diff, find_model_names, middle_idx, order_stat_heuristic,
diag_elpd, diag_diff and print.compare.loo it also held were dead: alphabetical
collation meant model_compare.R won. diag_diff and print.compare.loo had
diverged, so the dead copies were also wrong. loo_compare_checks,
loo_compare_matrix, loo_compare_order and loo_order_stat_check are removed for
the same reason.

Warning helpers renamed to the package convention: .warn_insample_compare,
.warn_kfold_K_mismatch and .warn_omitted_compare_measures become
throw_*_warning(); .inform_compare_sign_conversion loses its dot prefix.

Adds loo_compare.psis_loo_ss_list so subsampling objects keep dispatching under
the old name, and fixes the undefined `loo3` in the loo_compare examples.

NAMESPACE and man/ still need regenerating with roxygen2 8.0.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The refactor copied an older print.compare.loo into model_compare.R, which
alphabetical collation made the live one, so the simplify argument added later
at a user's request stopped existing. Because print.compare.loo takes `...`,
`print(comp, simplify = FALSE)` was silently swallowed rather than erroring,
and the snapshot tests that would have caught it skip unless NOT_CRAN is set.

Merges the two: keeps the pred_measure dispatch, the compare_ref_model message
and .print_compare_diag_message() from the model_compare.R version, and restores
the flexible column selection and simplify argument from the loo_compare.R one.

test_compare.R with NOT_CRAN=true: 355 pass, 0 fail (was 352 pass, 3 fail on
8897e1f and on the split commit).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@VisruthSK VisruthSK added this to the v3.0.0 milestone Sep 17, 2026
…_compare

# Conflicts:
#	NEWS.md
#	R/loo.R
#	R/loo_compare.psis_loo_ss_list.R
#	R/pred_measure-builtin.R
#	R/pred_measure-compute.R
#	R/pred_measure.R
#	man/pred_measure.Rd
#	tests/testthat/_snaps/loo_subsampling_cases.md
#	tests/testthat/test_pred_measure.R
#	vignettes/articles-online-only/overview-measures.Rmd
#	vignettes/articles-online-only/pred-measure-workflow.Rmd
#	vignettes/loo2-large-data.Rmd
@florence-bockting florence-bockting changed the title Update loo_compare() to support new pred_measure API Introduce model_compare() for model comparison with support of new predictive measures Sep 21, 2026
@florence-bockting

Copy link
Copy Markdown
Contributor Author

Overview of changes

Features

Comparison

  • Compares all pred_measure types. All models in one call must use the same evaluation source. Mixed sources cause an error.
  • Matches measures on bare names. rmse_loo and rmse_kfold both become rmse.
  • Uses the best model of each measure as the reference for that measure. The attribute compare_reference records each reference.
  • Orders rows by the first shared measure (elpd if it is present).
  • Accepts named models: model_compare(A = m1, B = m2).
  • Still supports subsampled LOO (psis_loo_ss).

Standard error of differences

  • "sum" and "mean": from paired pointwise differences.
  • "measure_specific": special formulas for rmse, r2, and bacc.
  • "custom": set with attr(fun, "measure_se_diff") or custom_measure(). If a custom measure sets nothing, the SE is NA. A message then tells the user.

Checks

Condition Output
Models use different measure sets warning (lists omitted measures)
Models disagree on loss or se_diff_fun error
Models use different y warning
k-fold models use different K or folds warning
In-sample comparison warning (optimistic bias)
More than 11 models warning (order-statistic check)
loo_moment_match() or reloo() with a measure other than elpd, mlpd, ic warning

Print method (print.compare.loo())

  • measures: NULL (first measure), "all", or a character vector.
  • simplify = FALSE: also shows the per-model estimate and SE.
  • digits = NULL: sets the digits for each measure.
  • Prints the reference model for each measure table.
  • Prints the PSIS Pareto k diagnostics once per model, above the tables.
  • Marks each loss measure with sign flipped in the table header. A note below the tables names these measures.
  • Shows the diagnostic glossary only when the output includes an ELPD measure.
  • Keeps each output line at 80 characters or fewer.

User-facing functions

Function Status
model_compare() (methods default, psis_loo_ss_list) new
custom_measure() new
print.compare.loo() new arguments measures, simplify
loo_compare() deprecated
print.compare.loo_ss() removed

Internal functions

R/model_compare.R

  • New: .model_compare_inputs(), .measure_ref_model(), .model_compare_estimates_table(), throw_kfold_K_mismatch_warning(), throw_kfold_folds_mismatch_warning()
  • Renamed from loo_compare_*: model_compare_checks(), model_compare_matrix(), model_compare_order(), model_order_stat_check()
  • Moved from R/loo_compare.R: elpd_diffs(), se_elpd_diff(), find_model_names(), middle_idx(), order_stat_heuristic(), diag_elpd(), diag_diff()

R/model_compare-pred_measure.R (new file)

  • Dispatch and checks: is.pred_measure(), is.loo_pred_measure(), compare_pred_measure(), .compare_source(), .compare_metadata_check()
  • Measure sets: .compare_measures(), .compare_pointwise_cols(), .resolve_rank_measure(), .is_elpd_measure(), .get_measure_info()
  • Orientation: .builtin_loss_measures(), .measure_is_loss(), .compare_sign_converted_measures()
  • SE of differences: .measure_pointwise_diff_method(), .check_declared_aggregation(), .measure_se_diff_fun(), .check_se_diff_value(), .resolve_custom_se_diffs(), .se_diff_input(), .validate_se_diff(), .pair_measure_stats()
  • Conditions: throw_insample_compare_warning(), throw_omitted_compare_measures_warning(), inform_missing_custom_se_diff()

R/model_compare-print.R (new file)

  • .print_compare_pred_measure(), .print_compare_measure_table(), .print_psis_diag_block(), .print_compare_diag_message(), .compare_reference_line(), .compare_source_phrase(), .parse_diag_psis(),
    .compare_suffix(), .display_name(), .n_of_models(), .cat_wrapped(), .print_sign_flip_note()

R/model_compare.psis_loo_ss_list.R (renamed from loo_compare.psis_loo_ss_list.R)

  • model_compare_ss(), model_compare_ss_naive(), model_compare_ss_diff()

R/pred_measure-builtin.R

  • .se_diff_rmse(), .se_diff_r2(), .se_diff_bacc(), .se_r2_delta(), .measure_info()

R/pred_measure-compute.R

  • .detect_posthoc(), .warn_posthoc()

R/print.R

  • .measure_digits(), .se_digits(), .resolve_digits(), .format_estimates()

Florence Bockting added 2 commits September 28, 2026 11:24
…_compare

# Conflicts:
#	R/pred_measure-compute.R
#	R/pred_measure-helpers.R
#	R/pred_measure.R
#	man/insample_pred_measure.Rd
#	man/pred_measure_params.Rd
@florence-bockting
florence-bockting marked this pull request as ready for review September 28, 2026 09:00

@jgabry jgabry left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks @florence-bockting, this is great! Here's a first round of review comments, but I probably missed some things. I should probably do another round, but we can start with these first and then I'll review again

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

With the addition of a file this big the package tarball goes from about 4.1 MB to 7.3 MB, which is over CRAN's 5 MB limit. We might be able to get an exception, but I'm not sure. I guess the issue is that it stores full ypred and mupred draws for three models. Could we use fewer draws, e.g. 100 maybe? Or could we use a script to generate this instead of saving it? Would that be too slow?

@florence-bockting florence-bockting Sep 29, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I reduced the size in two ways:

  • test_data_roaches_compare.Rds now comes from a brms fit with thin = 4, so it holds 100 draws instead of 400.
  • All test_data_*.Rds files are now saved with compress = "xz".

The tarball is now about 4.4 MB, built without the vignettes.

Commit: ee7b

if (!length(cols)) {
stop("No measure is shared by all models.", call. = FALSE)
}
internal <- cols[1L]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The doc says if elpd is present then rows are ordered by elpd, but the code doesn't prefer elpd when present. I think it just takes the first shared measure of whichever model you pass first, regardless of whether elpd is present, right?

Also, if the models list measures in different orders, then the ranking measure depends on the order you pass the models in, so e.g. if you pass in the models as list(m1, m2) that can lead to a different measure used as the ranking measure compared to list(m2, m1). Is that intentional?

Should we prefer elpd explicitly and if there's no elpd then use an order that doesn't depend on which model comes first?

@florence-bockting florence-bockting Sep 29, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thank you.

  • The ranking measure is now elpd if all models share it. Otherwise it is the first shared measure in alphabetical order. The other measures follow in alphabetical order.
  • The order no longer depends on the order of the models.
  • The print method and the compare_measures attribute use the same order.

Tests:

  • updated existing tests: without elpd now expects alphabetical order of measures; with elpd expects elpd to be first.
  • added new test that checks list(m1, m2) and list(m2, m1) give the same measure order.

I updated the docs for the new behavior: NEWS.md, model_compare documentation, the glossary, and the model-comparison vignette.

Commit: fd42

Comment thread R/model_compare-pred_measure.R Outdated
intersect,
lapply(loos, function(x) colnames(x$pointwise))
)
cols[!grepl("^p_", cols)]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this means you can't have a custom measure with a name that starts with p_ because this will drop it. I think just dropping columns by exact name would fix it.

@florence-bockting florence-bockting Sep 29, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point.
I changed it to: cols <- cols[!cols %in% c("p_loo", "p_waic", "p_kfold")] and added a test that ensures a custom measure starting with p_ is not dropped.

Commit: f7a7

Comment thread R/model_compare.R
#' print(comp, measures = "all") # all measure diff tables
#'
#' # the same works for k-fold CV
#' kf1 <- brms::kfold(fit1, folds = folds, save_fits = TRUE)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This errors because folds isn't defined

@florence-bockting florence-bockting Sep 29, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

indeed. I added a definition of folds before calling brms::kfold

Commit: a509

Comment thread R/model_compare.R Outdated
#' measures = c("rmse", "r2")
#' )
#' comp <- model_compare(pm1, pm2)
#' print(comp) # ranked by elpd (default)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

the comment says ranked by elpd (default) but elpd isn't one of the measures, there's only rmse and r2 here

@florence-bockting florence-bockting Sep 29, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

thanks, I removed the comment. (The ordering information is now already at several places in the documentation so no need for a code comment)

Commit: 102a

toc: true
toc_depth: 3
params:
EVAL: TRUE #!r identical(Sys.getenv("NOT_CRAN"), "true")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do we definitely want this as online only? If so, we can remove the commented out part here. Or should we consider it to be included in the CRAN package too? I don't have a very strong opinion, just curious what your thoughts were about this.

print(model_compare(loos), simplify = FALSE)
```

## Preview: Glimpse into the model comparison results

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we should maybe just title this section "Basic usage", even though it's not a super exciting title

Comment thread R/pred_measure.R
attr(fun, "measure_name") <- name
attr(fun, "measure_loss") <- loss
attr(fun, "measure_se_diff") <- se_diff_fun
.measure_entry_custom(fun)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This calls .measure_entry_custom() only for the validation checks and throws away the result, but that means lines 80-84 of pred_measure-helpers.R can produce a confusing error message for bad name arguments. custom_measure(f, name = "") or name = NULL, or name = c("a", "b"), etc., get this error message:

Error: A custom function passed to 'measure' must have attribute 'measure_name', e.g. attr(my_fun, "measure_name") <- "my_metric".

But the user never passed anything to measure and also the error message suggests they should assign the attribute themselves, but custom_message exists to avoid them having to do that.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Just noticed that the other errors thrown by .measure_entry_custom() (e.g. for loss and se_diff) also suggest assigning the attribute directly. I guess that's still supported behavior, but not what we prefer if custom_measure exists.

I think there are a couple ways to potentially handle all of these:

  1. Use custom_measure in the wording of the messages, e.g "Custom measure 'huber' must declare loss as TRUE or FALSE; see custom_measure()"

or

  1. Let the calling function pick the wording. There's already an origin argument for .check_se_diff_value() for this, so .measure_entry_custom could do something similar and custom_measure could pass it the argument names.

I would prefer option 1 probably, and updating the examples to use custom_measure instead of assigning attributes even if technically that's still allowed.

Comment thread R/pred_measure.R
#'
#' Custom measures are assumed to be on a utility scale (higher is better) in
#' [model_compare()]. Declare a custom loss with
#' `attr(my_fun, "measure_loss") <- TRUE` so that [model_compare()] converts and

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should we avoid suggesting to assign attributes and just encourage using the custom_measure function? Same for se diff below

Comment thread R/pred_measure.R
Comment on lines 98 to +100
#' attr(my_abs_err, "measure_name") <- "my_abs_err"
#' # the estimate is the mean of the pointwise values, so declare "mean"
#' attr(my_abs_err, "measure_se_diff") <- "mean"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

same point as above about whether we should be showing custom_measure instead?

This branch has not been deployed

No deployments
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.

4 participants