Skip to content

Commit abc74b5

Browse files
author
Florence Bockting
committed
refactor: update computation of SE for loo_compare with pred_measure_loo
1 parent e17e46d commit abc74b5

18 files changed

Lines changed: 1793 additions & 163 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,5 @@ release-prep.R
3030

3131
# personal maintainer scratch (not shared)
3232
internal-notes/
33+
notes/loo_se.pdf
34+
notes/loo-compare-se-diff.md

R/loo-glossary.R

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -266,25 +266,37 @@
266266
#' ### `{measure}_diff` and `{measure}_se_diff`
267267
#'
268268
#' For each non-ELPD measure `m`, `loo_compare()` adds columns `m_diff` and
269-
#' `m_se_diff`. When the overall estimate is a sum or mean of pointwise
270-
#' contributions, these are computed from paired pointwise differences on a
271-
#' utility scale (higher is better; loss measures such as MSE, Brier score, and
272-
#' SRPS have their sign flipped from the raw loss orientation) using the same
273-
#' approach as `elpd_diff` and `se_diff` (Eq 24 in VGG2017 for sums; the mean
274-
#' analogue for means). Measures already returned on a utility scale (e.g. ELPD,
275-
#' CRPS/RPS) are not sign-flipped. Negative `m_diff` values then indicate worse
276-
#' performance than the reference model, which has `m_diff = 0`. For sum- and
277-
#' mean-based measures, the reference model also has `m_se_diff = 0`; for
278-
#' `estimates_only` measures (e.g. `r2`, `mse`, `rmse`), `m_se_diff` is `NA`.
269+
#' `m_se_diff`. In all cases `m_diff` is the difference between the two overall
270+
#' estimates on a utility scale (higher is better; loss measures such as MSE,
271+
#' Brier score, and SRPS have their sign flipped from the raw loss orientation).
272+
#' Measures already returned on a utility scale (e.g. ELPD, CRPS/RPS) are not
273+
#' sign-flipped. Negative `m_diff` values then indicate worse performance than
274+
#' the reference model, which has `m_diff = 0`.
275+
#'
276+
#' How `m_se_diff` is obtained depends on the measure:
277+
#'
278+
#' * When the overall estimate is a sum or mean of pointwise contributions, it
279+
#' is computed from paired pointwise differences using the same approach as
280+
#' `elpd_diff` and `se_diff` (Eq 24 in VGG2017 for sums; the mean analogue for
281+
#' means). This covers ELPD, `mlpd`, `ic`, `mae`, `mse`, `acc`, `brier`, and
282+
#' the ranked probability scores.
283+
#' * When it is a transformation of such quantities, the measure supplies its
284+
#' own delta-method standard error (`se_diff_fun`). For `rmse` this is the
285+
#' first-order bivariate Taylor approximation propagated from the MSE scale,
286+
#' which requires the covariance between the two models' pointwise squared
287+
#' errors and is therefore not a paired pointwise standard deviation. For
288+
#' `r2` it is the trivariate analogue, which additionally propagates the
289+
#' uncertainty in the baseline `MSE(y)` shared by both models.
290+
#' * Otherwise `m_se_diff` is `NA`. This applies only to custom measures whose
291+
#' estimate is neither a sum nor a mean of their pointwise values and which do
292+
#' not attach an `se_diff_fun`.
293+
#'
294+
#' The reference model has `m_se_diff = 0` whenever an `m_se_diff` is available.
279295
#' Attribute `measure_higher_is_better` on each `*_pred_measure()`
280296
#' result records the `higher_is_better` setting used when each measure was
281297
#' computed; when stored values are on a loss scale, `loo_compare()` emits a
282298
#' short message naming those measures (see [loo_compare()]).
283299
#'
284-
#' For measures where pointwise values do not define the overall estimate (e.g.
285-
#' `r2`, `mse`, `rmse`), `m_diff` is the difference between overall estimates
286-
#' (on a utility scale) and `m_se_diff` is `NA`.
287-
#'
288300
#' ELPD-family measures use the column names `elpd_diff` and `se_diff` rather
289301
#' than a prefixed form. Only ELPD comparisons include `p_worse` and `diag_diff`;
290302
#' these diagnostics do not apply to other predictive measures.
@@ -306,12 +318,29 @@
306318
#' * `higher_is_better` — the orientation setting used when the measure was
307319
#' computed (`NULL`, `TRUE`, or `FALSE`)
308320
#' * `loss` — whether stored values are on a loss scale (lower is better)
309-
#' * `diff_method` — how paired differences are aggregated: `"sum"`,
310-
#' `"mean"`, `"estimates_only"`, or `"auto"` (inferred at compare time for
311-
#' custom measures)
312-
#'
313-
#' Built-in measures take `loss` and `diff_method` from the package measure
314-
#' registry; custom measures default to `loss = FALSE` and `diff_method = "auto"`.
321+
#' * `diff_method` — how the standard error of the difference is obtained:
322+
#' `"sum"` or `"mean"` (paired pointwise differences), `"pairwise"` (the
323+
#' measure's own `se_diff_fun`), `"estimates_only"` (unavailable, `NA`), or
324+
#' `"auto"` (inferred at compare time for custom measures). No built-in
325+
#' measure declares `"estimates_only"`; it is reached only when
326+
#' autodetection under `"auto"` cannot establish that the estimate is a sum
327+
#' or mean of its pointwise values. It is not an error state — the
328+
#' difference itself is still reported, and only its standard error is
329+
#' marked unavailable.
330+
#' * `se_diff_fun` — for `diff_method = "pairwise"`, either the name of a
331+
#' built-in implementation or, for custom measures, the function itself
332+
#' * `extra` — optional list of auxiliary data the measure stored for its
333+
#' `se_diff_fun`, present only for measures that need it (`r2` stores the
334+
#' pointwise baseline `(y_i - mean(y))^2`, which `y` no longer supplies by
335+
#' the time [loo_compare()] runs; `bacc` stores the class index of each
336+
#' observation, which its pointwise values do not determine). It is excluded
337+
#' from the metadata consistency check below, since it varies with the data
338+
#' rather than with how the measure was configured.
339+
#'
340+
#' Built-in measures take `loss`, `diff_method`, and `se_diff_fun` from the
341+
#' package measure registry; custom measures default to `loss = FALSE` and
342+
#' `diff_method = "auto"` unless they attach an `se_diff_fun` (see
343+
#' [insample_pred_measure()]).
315344
#' [loo_compare()] requires all models to provide matching metadata for each
316345
#' shared measure; mismatched `higher_is_better` settings or missing metadata on
317346
#' some models produce an error.
@@ -322,10 +351,9 @@
322351
#' the reference model for all pairwise differences. When `rank_by` is omitted,
323352
#' models are ranked by `"elpd"`; attribute `rank_by` is set only when `rank_by`
324353
#' is passed explicitly. Attribute `compare_measures` lists all measures that
325-
#' were compared, `sign_converted_measures` lists loss measures whose sign was
326-
#' flipped onto the utility scale, and `measures_no_pointwise_se` lists measures
327-
#' for which `{measure}_se_diff` is unavailable (overall estimate not defined
328-
#' from pointwise values). The print method shows the ranking measure by default
354+
#' were compared, and `sign_converted_measures` lists loss measures whose sign
355+
#' was flipped onto the utility scale. The print method shows the ranking
356+
#' measure by default
329357
#' (`"elpd"` when `rank_by` was not set); use `print(x, measures = "all")` or
330358
#' `print(x, measures = c("rmse", "r2"))` to display additional measure tables.
331359
#' Printed tables label the standard-error column `se_diff` even for non-ELPD

R/loo_compare.R

Lines changed: 122 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,6 @@
3232
#' `sign_converted_measures` record which measures were compared and which
3333
#' loss measures had their sign flipped for comparison. Attribute `rank_by` is
3434
#' set when `rank_by` was passed explicitly (default ranking is by `"elpd"`).
35-
#' Attribute `measures_no_pointwise_se` lists measures without pointwise-based
36-
#' `{measure}_se_diff` values.
3735
#'
3836
#' @details
3937
#' When comparing two fitted models, we can estimate the difference in their
@@ -397,8 +395,6 @@ print.compare.loo <- function(x, ..., digits = 1, p_worse = TRUE, measures = NUL
397395
}
398396
}
399397

400-
.warn_measures_no_pointwise_se(attr(x, "measures_no_pointwise_se"))
401-
402398
invisible(x)
403399
}
404400

@@ -529,13 +525,9 @@ compare_loo_pred_measure <- function(loos, rank_by = NULL) {
529525
n_obs <- nrow(loos_ord[[1L]]$pointwise)
530526

531527
diff_cols <- list()
532-
measures_no_pointwise_se <- list()
533528
for (col in compare_cols) {
534529
bare <- .display_name(col)
535530
method <- .measure_pointwise_diff_method(loos_ord, col)
536-
if (method == "estimates_only") {
537-
measures_no_pointwise_se[[bare]] <- bare
538-
}
539531
pair_stats <- vapply(
540532
loos_ord,
541533
.pair_measure_stats,
@@ -578,7 +570,6 @@ compare_loo_pred_measure <- function(loos, rank_by = NULL) {
578570
rank_col = rank_measure$internal
579571
)
580572

581-
attr(comp, "measures_no_pointwise_se") <- unique(unlist(measures_no_pointwise_se))
582573
if (!is.null(rank_by)) {
583574
attr(comp, "rank_by") <- rank_measure$bare
584575
}
@@ -652,7 +643,15 @@ compare_loo_pred_measure <- function(loos, rank_by = NULL) {
652643
call. = FALSE
653644
)
654645
}
655-
non_null <- metas[has_meta]
646+
# `extra` holds per-measure auxiliary data (for `r2`, the pointwise
647+
# baseline derived from `y`), which legitimately differs when models are
648+
# fitted to different data. That case is already reported by the `yhash`
649+
# warning, so comparing `extra` here would only mislabel it as a
650+
# `higher_is_better` disagreement.
651+
non_null <- lapply(metas[has_meta], function(meta) {
652+
meta$extra <- NULL
653+
meta
654+
})
656655
if (length(non_null) > 1L) {
657656
ref <- non_null[[1L]]
658657
inconsistent <- vapply(
@@ -721,23 +720,6 @@ compare_loo_pred_measure <- function(loos, rank_by = NULL) {
721720
)
722721
}
723722

724-
#' Warn when `se_diff` is unavailable for compared measures
725-
#' @noRd
726-
.warn_measures_no_pointwise_se <- function(measures) {
727-
if (!length(measures)) {
728-
return(invisible(NULL))
729-
}
730-
warning(
731-
paste0(
732-
"se_diff unavailable for: ",
733-
paste(measures, collapse = ", "),
734-
"."
735-
),
736-
call. = FALSE
737-
)
738-
invisible(NULL)
739-
}
740-
741723
#' Bare measure names available for comparison across models
742724
#' @noRd
743725
.compare_measures <- function(loos) {
@@ -773,18 +755,38 @@ compare_loo_pred_measure <- function(loos, rank_by = NULL) {
773755
compare_meta[[bare]]
774756
}
775757

758+
#' Whether a measure is intrinsically a loss (natural scale: lower is better)
759+
#'
760+
#' Unlike `.measure_lower_is_better()` this ignores `higher_is_better`, so it
761+
#' describes the measure itself rather than the scale its values are stored on.
762+
#' @noRd
763+
.measure_is_loss <- function(name, loos = NULL) {
764+
bare <- .display_name(name)
765+
766+
if (!is.null(loos)) {
767+
meta <- .get_measure_compare_meta(loos, bare)
768+
if (!is.null(meta) && !is.null(meta$loss)) {
769+
return(isTRUE(meta$loss))
770+
}
771+
}
772+
773+
spec <- .measure_spec[[bare]]
774+
if (!is.null(spec)) {
775+
return(isTRUE(spec$loss))
776+
}
777+
bare %in% c("ic", "mae", "mse", "rmse", "brier", "srps")
778+
}
779+
776780
#' Whether stored values are on a loss scale (lower is better)
777781
#' @noRd
778782
.measure_lower_is_better <- function(name, loos = NULL) {
779783
bare <- .display_name(name)
780784
higher_is_better <- NULL
781-
loss <- NULL
782785

783786
if (!is.null(loos)) {
784787
meta <- .get_measure_compare_meta(loos, bare)
785788
if (!is.null(meta)) {
786789
higher_is_better <- meta$higher_is_better
787-
loss <- meta$loss
788790
} else {
789791
hib_attr <- attr(loos[[1L]], "measure_higher_is_better")
790792
if (!is.null(hib_attr) && bare %in% names(hib_attr)) {
@@ -797,16 +799,18 @@ compare_loo_pred_measure <- function(loos, rank_by = NULL) {
797799
return(!isTRUE(higher_is_better))
798800
}
799801

800-
if (is.null(loss)) {
801-
spec <- .measure_spec[[bare]]
802-
loss <- if (!is.null(spec)) {
803-
isTRUE(spec$loss)
804-
} else {
805-
bare %in% c("ic", "mae", "mse", "rmse", "brier", "srps")
806-
}
807-
}
802+
.measure_is_loss(name, loos)
803+
}
808804

809-
isTRUE(loss)
805+
#' Sign converting stored measure values to the measure's natural scale
806+
#'
807+
#' `higher_is_better` may have negated the stored values (see
808+
#' `.create_measure_structure()`). Delta-method standard errors are derived on
809+
#' the natural scale (e.g. RMSE positive), so they must be undone first.
810+
#' @noRd
811+
.measure_natural_sign <- function(name, loos = NULL) {
812+
stored_lower_is_better <- .measure_lower_is_better(name, loos)
813+
if (identical(stored_lower_is_better, .measure_is_loss(name, loos))) 1 else -1
810814
}
811815

812816
#' Bare names of measures whose sign is flipped for `loo_compare()`
@@ -832,16 +836,17 @@ compare_loo_pred_measure <- function(loos, rank_by = NULL) {
832836
paste(converted, collapse = ", "),
833837
" ",
834838
if (length(converted) == 1L) "is" else "are",
835-
" reported on a utility scale (higher is better)."
839+
"\nreported on a utility scale (higher is better)."
836840
)
837841
invisible(NULL)
838842
}
839843

840844
#' How to aggregate paired pointwise differences for a measure
841845
#'
842846
#' Returns `"sum"` when the overall estimate equals the sum of pointwise
843-
#' contributions, `"mean"` when it equals the mean, and `"estimates_only"`
844-
#' when pointwise values do not define the overall estimate.
847+
#' contributions, `"mean"` when it equals the mean, `"pairwise"` when the
848+
#' measure supplies its own `se_diff_fun`, and `"estimates_only"` when pointwise
849+
#' values do not define the overall estimate and no `se_diff_fun` is available.
845850
#' @noRd
846851
.measure_pointwise_diff_method <- function(loos, col) {
847852
bare <- .display_name(col)
@@ -850,10 +855,6 @@ compare_loo_pred_measure <- function(loos, rank_by = NULL) {
850855
return(meta$diff_method)
851856
}
852857

853-
spec <- .measure_spec[[bare]]
854-
if (!is.null(spec) && identical(spec$diff_method, "estimates_only")) {
855-
return("estimates_only")
856-
}
857858
if (.is_elpd_measure(col) || bare == "ic") {
858859
return("sum")
859860
}
@@ -875,6 +876,65 @@ compare_loo_pred_measure <- function(loos, rank_by = NULL) {
875876
"estimates_only"
876877
}
877878

879+
#' Resolve a measure's `se_diff_fun`
880+
#'
881+
#' Built-in measures name an entry of `.se_diff_funs`; custom measures store the
882+
#' function itself (see `.measure_compare_meta()`).
883+
#' @noRd
884+
.measure_se_diff_fun <- function(loos, col) {
885+
bare <- .display_name(col)
886+
meta <- .get_measure_compare_meta(loos, bare)
887+
888+
fun <- meta$se_diff_fun
889+
if (is.null(fun)) {
890+
fun <- .measure_spec[[bare]]$se_diff_fun
891+
}
892+
if (is.character(fun)) {
893+
fun <- .se_diff_funs[[fun]]
894+
}
895+
if (!is.function(fun)) {
896+
stop(
897+
paste0(
898+
"No 'se_diff_fun' available for measure '", bare, "'."
899+
),
900+
call. = FALSE
901+
)
902+
}
903+
fun
904+
}
905+
906+
#' Assemble one model's inputs for an `se_diff_fun`
907+
#'
908+
#' Every element describes the single model `x`, including `extra`, which is
909+
#' read from that model's own comparison metadata rather than the reference
910+
#' model's.
911+
#' @noRd
912+
#' @param sgn Sign restoring the measure's natural scale, see
913+
#' `.measure_natural_sign()`.
914+
.se_diff_input <- function(x, col, sgn) {
915+
list(
916+
estimate = sgn * x$estimates[col, "Estimate"],
917+
se = x$estimates[col, "SE"],
918+
pointwise = sgn * x$pointwise[, col, drop = TRUE],
919+
extra = .get_measure_compare_meta(list(x), .display_name(col))$extra
920+
)
921+
}
922+
923+
#' Validate the value returned by an `se_diff_fun`
924+
#' @noRd
925+
.validate_se_diff <- function(se, col) {
926+
if (!is.numeric(se) || length(se) != 1L) {
927+
stop(
928+
paste0(
929+
"The 'se_diff_fun' for measure '", .display_name(col),
930+
"' must return a numeric scalar."
931+
),
932+
call. = FALSE
933+
)
934+
}
935+
unname(se)
936+
}
937+
878938
#' Paired measure difference and SE for one model vs a reference
879939
#' @noRd
880940
.pair_measure_stats <- function(cmp, ref, col, method = NULL, loos = list(ref)) {
@@ -883,18 +943,31 @@ compare_loo_pred_measure <- function(loos, rank_by = NULL) {
883943
}
884944

885945
flip <- .measure_lower_is_better(col, loos)
946+
est_utility <- function(estimates) {
947+
val <- estimates[col, "Estimate"]
948+
if (flip) -val else val
949+
}
886950

887951
if (method == "estimates_only") {
888-
est_utility <- function(estimates) {
889-
val <- estimates[col, "Estimate"]
890-
if (flip) -val else val
891-
}
892952
return(c(
893953
diff = est_utility(cmp$estimates) - est_utility(ref$estimates),
894954
se = NA_real_
895955
))
896956
}
897957

958+
if (method == "pairwise") {
959+
se_diff_fun <- .measure_se_diff_fun(loos, col)
960+
sgn <- .measure_natural_sign(col, loos)
961+
se <- se_diff_fun(
962+
ref = .se_diff_input(ref, col, sgn),
963+
cmp = .se_diff_input(cmp, col, sgn)
964+
)
965+
return(c(
966+
diff = est_utility(cmp$estimates) - est_utility(ref$estimates),
967+
se = .validate_se_diff(se, col)
968+
))
969+
}
970+
898971
to_utility <- function(pointwise) {
899972
x <- pointwise[, col, drop = TRUE]
900973
if (flip) -x else x

0 commit comments

Comments
 (0)