Skip to content

Commit 1b4a2be

Browse files
Alternative forecasting method: incremental bayes (#20)
* add more stats * add tests + update docs * fix tests * minor build fixes * Apply suggestion from @mccarthy-m-g Co-authored-by: Michael McCarthy <51542091+mccarthy-m-g@users.noreply.github.com> * Apply suggestion from @mccarthy-m-g Co-authored-by: Michael McCarthy <51542091+mccarthy-m-g@users.noreply.github.com> * Apply suggestion from @mccarthy-m-g Co-authored-by: Michael McCarthy <51542091+mccarthy-m-g@users.noreply.github.com> * Apply suggestion from @mccarthy-m-g Co-authored-by: Michael McCarthy <51542091+mccarthy-m-g@users.noreply.github.com> * Apply suggestion from @mccarthy-m-g Co-authored-by: Michael McCarthy <51542091+mccarthy-m-g@users.noreply.github.com> * Apply suggestion from @mccarthy-m-g Co-authored-by: Michael McCarthy <51542091+mccarthy-m-g@users.noreply.github.com> * Apply suggestion from @mccarthy-m-g Co-authored-by: Michael McCarthy <51542091+mccarthy-m-g@users.noreply.github.com> * Apply suggestion from @mccarthy-m-g Co-authored-by: Michael McCarthy <51542091+mccarthy-m-g@users.noreply.github.com> * Apply suggestion from @mccarthy-m-g Co-authored-by: Michael McCarthy <51542091+mccarthy-m-g@users.noreply.github.com> * Apply suggestion from @mccarthy-m-g Co-authored-by: Michael McCarthy <51542091+mccarthy-m-g@users.noreply.github.com> * move to S3 + print function + few more stats * fix tests * add incremental option * split off weighting function * fix docs + print function * add more testing * add tests for handle_sample_weighting() * Add more statistics to the output table (#28) * add ofv and ss * add test * Update R/run_eval_core.R Co-authored-by: Michael McCarthy <51542091+mccarthy-m-g@users.noreply.github.com> --------- Co-authored-by: Michael McCarthy <51542091+mccarthy-m-g@users.noreply.github.com> --------- Co-authored-by: Michael McCarthy <51542091+mccarthy-m-g@users.noreply.github.com>
1 parent 232e8dd commit 1b4a2be

10 files changed

Lines changed: 320 additions & 12 deletions

R/misc.R

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,19 @@ mape <- function (obs, pred) {
6767
mpe <- function (obs, pred) {
6868
sum((obs - pred)/obs)/length(obs)
6969
}
70+
71+
#' Weighted sum-of-squares of residuals
72+
#'
73+
#' @inheritParams rmse
74+
#' @param w weights
75+
#'
76+
ss <- function(obs, pred, w = NULL) {
77+
if(is.null(w)) {
78+
w <- rep(1, length(obs))
79+
}
80+
if (length(obs) != length(pred) || length(obs) != length(w)) {
81+
cli::cli_abort("`obs`, `pred`, and `w` must have the same length")
82+
}
83+
if(sum(w) == 0) return(NA)
84+
sum(w * (obs - pred)^2)
85+
}

R/print_functions.R

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
#'
66
#' @export
77
print.mipdeval_results <- function(x, ...) {
8+
if(is.null(x$results)) {
9+
cli::cli_alert_info("No forecasting info available, did you run with `vpc_only=TRUE`?")
10+
return(invisible())
11+
}
812
print(x$stats_summ, ...)
913
print(x$shrinkage, ...)
1014
print(x$bayesian_impact, ...)

R/run_eval.R

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
#' by default (so no data leakage), but it can be switched off if we want to
2323
#' match the behavior of `PsN::proseval` exactly.
2424
#' @param incremental should MAP Bayesian do incremental fits in the iterative
25-
#' loop. I.e. in this case it would use the first iterations MAP Bayesian
25+
#' loop? I.e. in this case it would use the first iterations MAP Bayesian
2626
#' estimates as input for the second iteration, and so forth. The uncertainty
2727
#' around the MAP estimates would be used as the new `omega` matrix. This
2828
#' approach has been called "model predictive control (MPC)"
@@ -119,6 +119,7 @@ run_eval <- function(
119119
mod_obj = mod_obj,
120120
censor_covariates = censor_covariates,
121121
weight_prior = weight_prior,
122+
incremental = incremental,
122123
progress_function = p,
123124
.threads = threads,
124125
.skip = .vpc_options$vpc_only

R/run_eval_core.R

Lines changed: 86 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,15 @@ run_eval_core <- function(
2626

2727
for(i in seq_along(iterations)) {
2828

29-
## TODO: this will change once we support sample-weighting strategies
30-
## For now this is just simple full-weighting for all previous points
31-
weights <- rep(0, nrow(obs_data))
32-
weights[obs_data[["_grouper"]] %in% iterations[1:i]] <- 1
29+
## Select which samples should be used in fit, for regular iterative
30+
## forecasting and incremental.
31+
## TODO: handle weighting down of earlier samples
32+
weights <- handle_sample_weighting(
33+
obs_data,
34+
iterations,
35+
incremental,
36+
i
37+
)
3338

3439
## Should covariate data be leaked? PsN::proseval does this,
3540
## but for use in MIPD they should be censored.
@@ -42,10 +47,15 @@ run_eval_core <- function(
4247
## Do a fit with PKPDmap, using only the points
4348
## with weight=1. The rest of the points will get a predicted value
4449
## in the output, but they were not weighted in the fit.
50+
mod_upd <- mod_obj
51+
if(incremental & i > 1) {
52+
mod_upd$parameters <- fit$parameters # take params from previous fit
53+
mod_upd$omega <- fit$vcov
54+
}
4555
fit <- PKPDmap::get_map_estimates(
4656
model = mod_obj$model,
47-
parameters = mod_obj$parameters,
48-
omega = mod_obj$omega,
57+
parameters = mod_upd$parameters,
58+
omega = mod_upd$omega,
4959
error = mod_obj$ruv,
5060
fixed = mod_obj$fixed,
5161
as_eta = mod_obj$kappa,
@@ -65,6 +75,8 @@ run_eval_core <- function(
6575
dv = fit$dv,
6676
ipred = fit$ipred,
6777
pred = fit$pred,
78+
ofv = fit$fit$value,
79+
ss_w = ss(fit$dv, fit$ipred, weights),
6880
`_iteration` = iterations[i],
6981
`_grouper` = obs_data$`_grouper`
7082
)
@@ -76,6 +88,41 @@ run_eval_core <- function(
7688
)
7789
}
7890

91+
## Get data for MAP fit
92+
if(incremental) {
93+
## need to do an extra MAP Bayesian fit, because we can't use the
94+
## incrementally updated parameters + omega
95+
fit_map <- PKPDmap::get_map_estimates(
96+
model = mod_obj$model,
97+
parameters = mod_obj$parameters, # use original model params!
98+
omega = mod_obj$omega, # use original model params!
99+
error = mod_obj$ruv,
100+
fixed = mod_obj$fixed,
101+
as_eta = mod_obj$kappa,
102+
data = data$observations,
103+
covariates = cov_data,
104+
regimen = data$regimen,
105+
weight_prior = weight_prior,
106+
weights = NULL, # no sample weighting, full MAP Bayesian on all samples
107+
iov_bins = mod_obj$bins,
108+
verbose = FALSE
109+
)
110+
map_pred_data <- tibble::tibble(
111+
id = obs_data$id,
112+
t = obs_data$t,
113+
dv = fit_map$dv,
114+
ipred = fit_map$ipred,
115+
pred = fit_map$pred,
116+
ofv = fit_map$fit$value,
117+
ss_w = ss(fit_map$dv, fit_map$ipred, w = NULL),
118+
`_iteration` = iterations[i],
119+
`_grouper` = obs_data$`_grouper`
120+
)
121+
} else { # just take last fit object and pred_data
122+
fit_map <- fit
123+
map_pred_data <- pred_data
124+
}
125+
79126
## pre-pend population predictions for the first observation
80127
# TODO: Refactor this logic into a function or functions, e.g., the first
81128
# argument to bind_rows() could be refactored into `get_apriori_data()`.
@@ -84,7 +131,9 @@ run_eval_core <- function(
84131
dplyr::filter(.data$`_iteration` == 1) |>
85132
dplyr::mutate(
86133
`_iteration` = 0,
87-
ipred = .data$pred
134+
ipred = .data$pred,
135+
ofv = NA,
136+
ss_w = NA
88137
) |> # set to population parameters, not individual estimates
89138
dplyr::select(-!!names(mod_obj$parameters)) |>
90139
dplyr::left_join(
@@ -96,17 +145,17 @@ run_eval_core <- function(
96145
dplyr::filter(.data$`_iteration` == (.data$`_grouper` - 1)) |>
97146
dplyr::mutate(
98147
iter_ipred = .data$ipred,
99-
map_ipred = pred_data$ipred, # ipred from last fit (full MAP)
148+
map_ipred = map_pred_data$ipred, # ipred from full retrospective MAP
100149
apriori = (.data$`_iteration` == 0)
101150
) |>
102151
dplyr::select(
103152
"id", "_iteration", "_grouper", "t", "dv", "pred", "map_ipred",
153+
"ofv", "ss_w",
104154
"iter_ipred", "apriori",
105155
!!names(mod_obj$parameters)
106156
)
107157

108158
out
109-
110159
}
111160

112161
#' Handle covariate censoring
@@ -136,3 +185,31 @@ handle_covariate_censoring <- function(
136185
}
137186
cov_data
138187
}
188+
189+
#' Handle weighting of samples
190+
#'
191+
#' This function is used to select the samples used in the fit (1 or 0),
192+
#' but also to select their weight, if a sample weighting strategy is
193+
#' selected.
194+
#'
195+
#' @inheritParams run_eval_core
196+
#' @param obs_data tibble or data.frame with observed data for individual
197+
#' @param iterations numeric vector of groups
198+
#' @param i index
199+
#'
200+
#' @returns vector of weights (numeric)
201+
#'
202+
handle_sample_weighting <- function(
203+
obs_data,
204+
iterations,
205+
incremental,
206+
i
207+
) {
208+
weights <- rep(0, nrow(obs_data))
209+
if(incremental) { # just fit current sample or group
210+
weights[obs_data[["_grouper"]] %in% iterations[i]] <- 1
211+
} else { # fit all samples up until current sample
212+
weights[obs_data[["_grouper"]] %in% iterations[1:i]] <- 1
213+
}
214+
weights
215+
}

man/handle_sample_weighting.Rd

Lines changed: 31 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/run_eval.Rd

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/run_eval_core.Rd

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/ss.Rd

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
test_that("handle_sample_weighting returns correct structure", {
2+
3+
# Create test data
4+
obs_data <- data.frame(
5+
id = c(1, 1, 1, 1),
6+
t = c(0, 12, 24, 48),
7+
dv = c(10, 8, 6, 4),
8+
`_grouper` = c(1, 2, 3, 4),
9+
check.names = FALSE
10+
)
11+
12+
iterations <- c(1, 2, 3, 4)
13+
14+
result <- handle_sample_weighting(obs_data, iterations, incremental = FALSE, i = 2)
15+
16+
expect_type(result, "double")
17+
expect_length(result, nrow(obs_data))
18+
expect_true(all(result %in% c(0, 1))) # Weights should be 0 or 1
19+
})
20+
21+
test_that("handle_sample_weighting works correctly for non-incremental mode", {
22+
obs_data <- data.frame(
23+
`_grouper` = c(1, 2, 3, 4, 5),
24+
check.names = FALSE
25+
)
26+
27+
iterations <- c(1, 2, 3, 4, 5)
28+
29+
# Test i = 1 (first iteration)
30+
weights_i1 <- handle_sample_weighting(obs_data, iterations, incremental = FALSE, i = 1)
31+
expect_equal(weights_i1, c(1, 0, 0, 0, 0))
32+
33+
# Test i = 3 (third iteration)
34+
weights_i3 <- handle_sample_weighting(obs_data, iterations, incremental = FALSE, i = 3)
35+
expect_equal(weights_i3, c(1, 1, 1, 0, 0))
36+
37+
# Test i = 5 (last iteration)
38+
weights_i5 <- handle_sample_weighting(obs_data, iterations, incremental = FALSE, i = 5)
39+
expect_equal(weights_i5, c(1, 1, 1, 1, 1))
40+
})
41+
42+
test_that("handle_sample_weighting works correctly for incremental mode", {
43+
obs_data <- data.frame(
44+
`_grouper` = c(1, 2, 3, 4, 5),
45+
check.names = FALSE
46+
)
47+
48+
iterations <- c(1, 2, 3, 4, 5)
49+
50+
# Test i = 1 (first iteration) - should only use first group
51+
weights_i1 <- handle_sample_weighting(obs_data, iterations, incremental = TRUE, i = 1)
52+
expect_equal(weights_i1, c(1, 0, 0, 0, 0))
53+
54+
# Test i = 3 (third iteration) - should only use third group
55+
weights_i3 <- handle_sample_weighting(obs_data, iterations, incremental = TRUE, i = 3)
56+
expect_equal(weights_i3, c(0, 0, 1, 0, 0))
57+
58+
# Test i = 5 (last iteration) - should only use fifth group
59+
weights_i5 <- handle_sample_weighting(obs_data, iterations, incremental = TRUE, i = 5)
60+
expect_equal(weights_i5, c(0, 0, 0, 0, 1))
61+
})
62+
63+
test_that("handle_sample_weighting handles multiple observations per group", {
64+
# Multiple observations can have the same _grouper value
65+
obs_data <- data.frame(
66+
`_grouper` = c(1, 1, 2, 2, 3, 3),
67+
check.names = FALSE
68+
)
69+
70+
iterations <- c(1, 2, 3)
71+
72+
# Non-incremental: i = 2 should include groups 1 and 2
73+
weights_non_inc <- handle_sample_weighting(obs_data, iterations, incremental = FALSE, i = 2)
74+
expect_equal(weights_non_inc, c(1, 1, 1, 1, 0, 0))
75+
76+
# Incremental: i = 2 should only include group 2
77+
weights_inc <- handle_sample_weighting(obs_data, iterations, incremental = TRUE, i = 2)
78+
expect_equal(weights_inc, c(0, 0, 1, 1, 0, 0))
79+
})
80+
81+
82+
test_that("handle_sample_weighting handles edge case with single observation", {
83+
obs_data <- data.frame(
84+
`_grouper` = c(1),
85+
check.names = FALSE
86+
)
87+
88+
iterations <- c(1)
89+
90+
# Both modes should give same result for single observation
91+
weights_non_inc <- handle_sample_weighting(obs_data, iterations, incremental = FALSE, i = 1)
92+
weights_inc <- handle_sample_weighting(obs_data, iterations, incremental = TRUE, i = 1)
93+
94+
expect_equal(weights_non_inc, c(1))
95+
expect_equal(weights_inc, c(1))
96+
})
97+
98+
test_that("handle_sample_weighting maintains correct vector length", {
99+
# Test with various data sizes
100+
for(n_obs in c(1, 5, 10, 100)) {
101+
obs_data <- data.frame(
102+
`_grouper` = rep(1:min(n_obs, 10), length.out = n_obs),
103+
check.names = FALSE
104+
)
105+
106+
iterations <- unique(obs_data[["_grouper"]])
107+
108+
weights <- handle_sample_weighting(obs_data, iterations, incremental = FALSE, i = 1)
109+
expect_length(weights, n_obs)
110+
}
111+
})

0 commit comments

Comments
 (0)