Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion R/get-coverage.R
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ get_coverage <- function(forecast, by = "model") {
# convert to wide interval format and compute interval coverage --------------
interval_forecast <- quantile_to_interval(forecast, format = "wide")
interval_forecast[,
interval_coverage := (observed <= upper) & (observed >= lower)
interval_coverage := check_interval_coverage(observed, lower, upper)
][, c("lower", "upper", "observed") := NULL]
interval_forecast[, interval_coverage_deviation :=
interval_coverage - interval_range / 100]
Expand Down
16 changes: 16 additions & 0 deletions R/helper-quantile-interval-range.R
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,19 @@ get_range_from_quantile <- function(quantile_level) {
)
return(interval_range)
}


#' Check whether observed values fall inside a prediction interval
#' @description
#' Internal helper that computes whether each observed value falls within the
#' bounds defined by `lower` and `upper`. Used by both [get_coverage()] and
#' [interval_coverage()] to avoid duplicating the bounds-check logic.
#' @param observed Numeric vector of observed values.
#' @param lower Numeric vector of lower interval bounds.
#' @param upper Numeric vector of upper interval bounds.
#' @returns A logical vector indicating whether each observed value falls
#' within the corresponding interval (inclusive on both bounds).
#' @keywords internal
check_interval_coverage <- function(observed, lower, upper) {
(observed >= lower) & (observed <= upper)
}
2 changes: 1 addition & 1 deletion R/metrics-quantile.R
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ interval_coverage <- function(observed, predicted,
r <- interval_range
reformatted <- quantile_to_interval(observed, predicted, quantile_level)
reformatted <- reformatted[interval_range %in% r]
reformatted[, interval_coverage := (observed >= lower) & (observed <= upper)]
reformatted[, interval_coverage := check_interval_coverage(observed, lower, upper)]
return(reformatted$interval_coverage)
}

Expand Down
25 changes: 25 additions & 0 deletions man/check_interval_coverage.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

112 changes: 112 additions & 0 deletions tests/testthat/test-get-coverage.R
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,118 @@
expect_s3_class(cov, c("data.table", "data.frame"), exact = TRUE)
})

test_that("get_coverage() interval coverage matches interval_coverage() for same data", {
# Regression guard: both functions independently compute the same bounds check
fc <- data.table::copy(example_quantile[model == "EuroCOVIDhub-ensemble"])
fc <- fc[!is.na(predicted)]
fc_obj <- as_forecast_quantile(fc)

cov <- get_coverage(fc_obj, by = get_forecast_unit(fc_obj))

# Compare for 50% interval — get_coverage returns multiple rows per forecast
# (one per quantile_level), but interval_coverage is the same for all rows
# with the same interval_range. Take unique per forecast unit + interval_range.
cov_50 <- unique(cov[interval_range == 50, c(get_forecast_unit(fc_obj),
"interval_range",

Check warning on line 44 in tests/testthat/test-get-coverage.R

View workflow job for this annotation

GitHub Actions / lint-changed-files

file=tests/testthat/test-get-coverage.R,line=44,col=48,[indentation_linter] Hanging indent should be 47 spaces but is 48 spaces.
"interval_coverage"),
with = FALSE])

# Get matching numeric data for interval_coverage()
obs <- fc[quantile_level == 0.5]$observed
pred_mat <- as.matrix(
data.table::dcast(
fc, ... ~ quantile_level, value.var = "predicted"
)[, .SD, .SDcols = as.character(sort(unique(fc$quantile_level)))]
)
ql <- sort(unique(fc$quantile_level))

ic_50 <- interval_coverage(obs, pred_mat, ql, interval_range = 50)
expect_equal(cov_50$interval_coverage, as.numeric(ic_50))
})

test_that("get_coverage() produces correct interval_coverage for known inputs", {
# Hand-crafted data with known expected coverage
dt1 <- data.table::data.table(
observed = rep(5, 3),
model = "m1", target_type = "t1",
target_end_date = as.Date("2020-01-01"), location = "loc1",
quantile_level = c(0.25, 0.5, 0.75),
predicted = c(3, 5, 7)
)
dt2 <- data.table::data.table(
observed = rep(10, 3),
model = "m1", target_type = "t1",
target_end_date = as.Date("2020-01-02"), location = "loc1",
quantile_level = c(0.25, 0.5, 0.75),
predicted = c(3, 5, 7)
)
dt <- rbind(dt1, dt2)
fc <- as_forecast_quantile(dt)

cov <- get_coverage(fc, by = get_forecast_unit(fc))

# For observed=5, 50% interval [3,7]: TRUE (5 >= 3 and 5 <= 7)
cov_50_obs5 <- cov[target_end_date == as.Date("2020-01-01") &
interval_range == 50]

Check warning on line 84 in tests/testthat/test-get-coverage.R

View workflow job for this annotation

GitHub Actions / lint-changed-files

file=tests/testthat/test-get-coverage.R,line=84,col=24,[indentation_linter] Indentation should be 23 spaces but is 24 spaces.
# interval_coverage is the same for all quantile_levels in this range
expect_true(all(cov_50_obs5$interval_coverage == TRUE))

Check warning on line 86 in tests/testthat/test-get-coverage.R

View workflow job for this annotation

GitHub Actions / lint-changed-files

file=tests/testthat/test-get-coverage.R,line=86,col=19,[redundant_equals_linter] Using == on a logical vector is redundant. Well-named logical vectors can be used directly in filtering. For data.table's `i` argument, wrap the column name in (), like `DT[(is_treatment)]`.

# For observed=10, 50% interval [3,7]: FALSE (10 > 7)
cov_50_obs10 <- cov[target_end_date == as.Date("2020-01-02") &
interval_range == 50]

Check warning on line 90 in tests/testthat/test-get-coverage.R

View workflow job for this annotation

GitHub Actions / lint-changed-files

file=tests/testthat/test-get-coverage.R,line=90,col=25,[indentation_linter] Indentation should be 24 spaces but is 25 spaces.
expect_true(all(cov_50_obs10$interval_coverage == FALSE))

Check warning on line 91 in tests/testthat/test-get-coverage.R

View workflow job for this annotation

GitHub Actions / lint-changed-files

file=tests/testthat/test-get-coverage.R,line=91,col=19,[redundant_equals_linter] Using == on a logical vector is redundant. Well-named logical vectors can be used directly in filtering. For data.table's `i` argument, wrap the column name in (), like `DT[(is_treatment)]`.

# Quantile coverage for quantile_level=0.5: TRUE for observed=5, FALSE for observed=10
qcov_obs5 <- cov[target_end_date == as.Date("2020-01-01") &
quantile_level == 0.5]

Check warning on line 95 in tests/testthat/test-get-coverage.R

View workflow job for this annotation

GitHub Actions / lint-changed-files

file=tests/testthat/test-get-coverage.R,line=95,col=22,[indentation_linter] Indentation should be 21 spaces but is 22 spaces.
expect_equal(nrow(qcov_obs5), 1)
expect_true(as.logical(qcov_obs5$quantile_coverage))

qcov_obs10 <- cov[target_end_date == as.Date("2020-01-02") &
quantile_level == 0.5]

Check warning on line 100 in tests/testthat/test-get-coverage.R

View workflow job for this annotation

GitHub Actions / lint-changed-files

file=tests/testthat/test-get-coverage.R,line=100,col=23,[indentation_linter] Indentation should be 22 spaces but is 23 spaces.
expect_equal(nrow(qcov_obs10), 1)
expect_false(as.logical(qcov_obs10$quantile_coverage))
})

test_that("get_coverage() and interval_coverage() agree when observation outside all intervals", {
dt <- data.table::data.table(
observed = rep(100, 5),
model = "m1", target_type = "t1",
target_end_date = as.Date("2020-01-01"), location = "loc1",
quantile_level = c(0.1, 0.25, 0.5, 0.75, 0.9),
predicted = c(1, 3, 5, 7, 9)
)
fc <- as_forecast_quantile(dt)
cov <- get_coverage(fc, by = get_forecast_unit(fc))

# All interval_coverage should be FALSE
expect_true(all(cov$interval_coverage == FALSE))

Check warning on line 117 in tests/testthat/test-get-coverage.R

View workflow job for this annotation

GitHub Actions / lint-changed-files

file=tests/testthat/test-get-coverage.R,line=117,col=19,[redundant_equals_linter] Using == on a logical vector is redundant. Well-named logical vectors can be used directly in filtering. For data.table's `i` argument, wrap the column name in (), like `DT[(is_treatment)]`.

# interval_coverage() should agree
pred_mat <- matrix(c(1, 3, 5, 7, 9), nrow = 1)
ql <- c(0.1, 0.25, 0.5, 0.75, 0.9)
expect_false(interval_coverage(100, pred_mat, ql, interval_range = 50))
expect_false(interval_coverage(100, pred_mat, ql, interval_range = 80))
})

test_that("refactored interval coverage produces identical output to original", {
# Comprehensive regression guard using full example dataset
cov <- get_coverage(example_quantile, by = get_forecast_unit(example_quantile))
scores <- score(example_quantile)

# Compare interval_coverage from get_coverage() for range=50 with score()'s interval_coverage_50
cov_50 <- cov[interval_range == 50]
# Merge on forecast unit to compare
fu <- get_forecast_unit(example_quantile)
merged <- merge(cov_50, scores, by = fu)
expect_equal(merged$interval_coverage, as.numeric(merged$interval_coverage_50))

# Same for range=90
cov_90 <- cov[interval_range == 90]
merged_90 <- merge(cov_90, scores, by = fu)
expect_equal(merged_90$interval_coverage, as.numeric(merged_90$interval_coverage_90))
})

test_that("get_coverage() can deal with non-symmetric prediction intervals", {
# the expected result is that `get_coverage()` just works. However,
# all interval coverages with missing values should just be `NA`
Expand Down
19 changes: 19 additions & 0 deletions tests/testthat/test-metrics-quantile.R
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,25 @@
)
})

test_that("interval_coverage() produces correct results for boundary cases", {
# Observation exactly on lower bound, upper bound, and inside
obs <- c(3, 7, 5)
pred <- matrix(c(3, 5, 7), nrow = 3, ncol = 3, byrow = TRUE)
ql <- c(0.25, 0.5, 0.75)
result <- interval_coverage(obs, pred, ql, interval_range = 50)
expect_equal(result, c(TRUE, TRUE, TRUE))
})

test_that("interval_coverage() handles multiple interval ranges correctly", {
obs <- c(5)
pred <- matrix(c(1, 3, 5, 7, 9), nrow = 1)
ql <- c(0.1, 0.25, 0.5, 0.75, 0.9)
# 50% interval: [3, 7]
expect_equal(interval_coverage(obs, pred, ql, interval_range = 50), TRUE)

Check warning on line 704 in tests/testthat/test-metrics-quantile.R

View workflow job for this annotation

GitHub Actions / lint-changed-files

file=tests/testthat/test-metrics-quantile.R,line=704,col=3,[expect_true_false_linter] expect_true(x) is better than expect_equal(x, TRUE)
# 80% interval: [1, 9]
expect_equal(interval_coverage(obs, pred, ql, interval_range = 80), TRUE)

Check warning on line 706 in tests/testthat/test-metrics-quantile.R

View workflow job for this annotation

GitHub Actions / lint-changed-files

file=tests/testthat/test-metrics-quantile.R,line=706,col=3,[expect_true_false_linter] expect_true(x) is better than expect_equal(x, TRUE)
})

test_that("interval_coverage_quantile throws a warning when a required quantile is not available", {
dropped_quantile_pred <- predicted[, -4]
dropped_quantiles <- quantile_level[-4]
Expand Down
Loading