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+ " \n reported 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