Skip to content

Commit 07556e6

Browse files
roninsightrxclaude
andcommitted
Add proper CWRES computation using FOCE linearization (Hooker et al. 2007)
Replace the incorrect ad-hoc CWRES normalization with proper FOCE-based conditional weighted residuals. The Jacobian F = df/deta is computed via central finite differences and used to derive both CWRES and the FOCE variance-covariance matrix, replacing the more expensive numDeriv::hessian computation when residuals=TRUE. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9d1ffa7 commit 07556e6

9 files changed

Lines changed: 527 additions & 18 deletions

DESCRIPTION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@ Depends: utils, stats
1212
Imports: PKPDsim, numDeriv, mvtnorm
1313
Suggests: testthat (>= 3.0.0)
1414
Remotes: InsightRX/PKPDsim
15-
RoxygenNote: 7.3.2
15+
RoxygenNote: 7.3.3
1616
Encoding: UTF-8
1717
Config/testthat/edition: 3

R/calc_cwres.R

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
#' Calculate CWRES (Conditional Weighted Residuals)
2+
#'
3+
#' Computes CWRES following the FOCE approximation method described in
4+
#' Hooker et al. (2007). The Jacobian of model predictions with respect to
5+
#' random effects (etas) is computed via central finite differences.
6+
#'
7+
#' CWRES = L^{-1} * (y - f(eta_hat) + F * eta_hat)
8+
#'
9+
#' where:
10+
#' - f(eta_hat) = individual predictions (IPRED) in the transformed space
11+
#' - F = Jacobian df/deta evaluated at eta_hat
12+
#' - L * L^T = V = F * Omega * F^T + Sigma (FOCE variance)
13+
#' - Sigma = diagonal residual error variance matrix
14+
#'
15+
#' @references Hooker AC, Staatz CE, Karlsson MO. Conditional weighted
16+
#' residuals (CWRES): a model diagnostic for the FOCE method.
17+
#' Pharm Res. 2007;24(12):2187-2197.
18+
#'
19+
#' @param eta_hat numeric vector of estimated etas (MAP estimates)
20+
#' @param ipred_raw numeric vector of raw (untransformed) individual
21+
#' predictions at eta_hat
22+
#' @param y numeric vector of observed values
23+
#' @param omega_full full omega covariance matrix
24+
#' @param error list with `prop` and `add` residual error components
25+
#' @param obs_type integer vector of observation types
26+
#' @param transf transformation function (identity or log for LTBS)
27+
#' @param model PKPDsim model object
28+
#' @param parameters_population list of population parameters
29+
#' @param nonfixed character vector of non-fixed parameter names
30+
#' @param as_eta character vector of parameters estimated directly as eta
31+
#' @param covariates list of PKPDsim covariates
32+
#' @param regimen PKPDsim regimen object
33+
#' @param lagtime lagtime specification
34+
#' @param t_obs numeric vector of observation times
35+
#' @param obs_type_sim observation type vector for simulation
36+
#' @param int_step_size integrator step size
37+
#' @param iov_bins IOV bin specification
38+
#' @param A_init initial state vector
39+
#' @param t_init initialization time
40+
#' @param steady_state_analytic steady state settings (or NULL)
41+
#' @param delta perturbation size for finite differences
42+
#' @param ... additional arguments passed to PKPDsim::sim_ode
43+
#'
44+
#' @return list with components:
45+
#' \item{cwres}{numeric vector of CWRES values}
46+
#' \item{vcov}{FOCE variance-covariance matrix of eta estimates
47+
#' (n_eta x n_eta), or NULL if computation failed. Derived from the
48+
#' same Jacobian F used for CWRES: vcov = (Omega^-1 + F' Sigma^-1 F)^-1}
49+
#'
50+
calc_cwres <- function(
51+
eta_hat,
52+
ipred_raw,
53+
y,
54+
omega_full,
55+
error,
56+
obs_type,
57+
transf,
58+
model,
59+
parameters_population,
60+
nonfixed,
61+
as_eta,
62+
covariates,
63+
regimen,
64+
lagtime,
65+
t_obs,
66+
obs_type_sim,
67+
int_step_size = 0.1,
68+
iov_bins = NULL,
69+
A_init = NULL,
70+
t_init = 0,
71+
steady_state_analytic = NULL,
72+
delta = 1e-4,
73+
...
74+
) {
75+
n_obs <- length(y)
76+
n_eta <- length(eta_hat)
77+
78+
if (n_obs == 0 || n_eta == 0) {
79+
return(list(cwres = numeric(0), vcov = NULL))
80+
}
81+
82+
# Transformed individual predictions at eta_hat
83+
ipred_transf <- transf(ipred_raw)
84+
85+
# Compute Jacobian F = d(transf(f))/d(eta) via central finite differences
86+
F_matrix <- matrix(0, nrow = n_obs, ncol = n_eta)
87+
for (j in seq_len(n_eta)) {
88+
eta_plus <- eta_hat
89+
eta_minus <- eta_hat
90+
eta_plus[j] <- eta_hat[j] + delta
91+
eta_minus[j] <- eta_hat[j] - delta
92+
93+
pred_plus <- simulate_with_etas(
94+
eta_plus, parameters_population, nonfixed, as_eta,
95+
model, covariates, regimen, lagtime, t_obs, obs_type_sim,
96+
int_step_size, iov_bins, A_init, t_init,
97+
steady_state_analytic, ...
98+
)
99+
pred_minus <- simulate_with_etas(
100+
eta_minus, parameters_population, nonfixed, as_eta,
101+
model, covariates, regimen, lagtime, t_obs, obs_type_sim,
102+
int_step_size, iov_bins, A_init, t_init,
103+
steady_state_analytic, ...
104+
)
105+
106+
F_matrix[, j] <- (transf(pred_plus) - transf(pred_minus)) / (2 * delta)
107+
}
108+
109+
# Residual error variance diagonal (in transformed space)
110+
sigma_diag <- error$prop[obs_type]^2 * ipred_transf^2 +
111+
error$add[obs_type]^2
112+
113+
# Omega submatrix for estimated parameters
114+
omega_est <- omega_full[seq_len(n_eta), seq_len(n_eta), drop = FALSE]
115+
116+
# FOCE approximate marginal variance: V = F * Omega * F^T + Sigma
117+
V <- F_matrix %*% omega_est %*% t(F_matrix) +
118+
diag(sigma_diag, nrow = n_obs)
119+
120+
# Cholesky decomposition (lower triangular)
121+
L <- tryCatch(
122+
t(chol(V)),
123+
error = function(e) NULL
124+
)
125+
cwres <- if (is.null(L)) {
126+
warning("CWRES computation failed: variance matrix is not positive definite.")
127+
rep(NA_real_, n_obs)
128+
} else {
129+
# CWRES = L^{-1} * (y_transf - ipred_transf + F * eta_hat)
130+
# Derivation:
131+
# E_FOCE(y) = f(eta_hat) - F * eta_hat (since E[eta] = 0)
132+
# y - E_FOCE(y) = y - f(eta_hat) + F * eta_hat
133+
y_transf <- transf(y)
134+
as.numeric(
135+
solve(L, y_transf - ipred_transf + F_matrix %*% eta_hat)
136+
)
137+
}
138+
139+
# FOCE Hessian: H = Omega^{-1} + F' * Sigma^{-1} * F
140+
# vcov of etas = H^{-1}
141+
# This reuses the Jacobian F already computed for CWRES, so no extra
142+
# simulations are needed (replaces the numDeriv::hessian computation).
143+
omega_est_inv <- tryCatch(solve(omega_est), error = function(e) NULL)
144+
vcov <- NULL
145+
if (!is.null(omega_est_inv)) {
146+
H_foce <- omega_est_inv +
147+
t(F_matrix) %*% diag(1 / sigma_diag, nrow = n_obs) %*% F_matrix
148+
vcov <- tryCatch(solve(H_foce), error = function(e) {
149+
warning("FOCE variance-covariance computation failed.")
150+
NULL
151+
})
152+
}
153+
154+
list(cwres = cwres, vcov = vcov)
155+
}
156+
157+
158+
#' Simulate predictions with a given eta vector
159+
#'
160+
#' Helper for computing the Jacobian in CWRES calculation via finite
161+
#' differences. Computes individual parameters from etas and runs a
162+
#' PKPDsim simulation.
163+
#'
164+
#' @keywords internal
165+
simulate_with_etas <- function(
166+
eta,
167+
parameters_population,
168+
nonfixed,
169+
as_eta,
170+
model,
171+
covariates,
172+
regimen,
173+
lagtime,
174+
t_obs,
175+
obs_type,
176+
int_step_size,
177+
iov_bins,
178+
A_init,
179+
t_init,
180+
steady_state_analytic,
181+
...
182+
) {
183+
# Compute individual parameters from etas
184+
par <- parameters_population
185+
for (i in seq_along(nonfixed)) {
186+
key <- nonfixed[i]
187+
if (key %in% as_eta) {
188+
par[[key]] <- eta[i]
189+
} else {
190+
par[[key]] <- par[[key]] * exp(eta[i])
191+
}
192+
}
193+
194+
# Recompute steady-state A_init if needed
195+
a_init <- A_init
196+
if (!is.null(steady_state_analytic)) {
197+
a_init <- PKPDsim::calc_ss_analytic(
198+
f = steady_state_analytic$f,
199+
dose = regimen$dose_amts[1],
200+
interval = regimen$interval[1],
201+
model = model,
202+
parameters = par,
203+
covariates = covariates,
204+
map = steady_state_analytic$map,
205+
n_transit_compartments = PKPDsim::ifelse0(
206+
steady_state_analytic$n_transit_compartments, FALSE
207+
),
208+
auc = PKPDsim::ifelse0(steady_state_analytic$auc, FALSE)
209+
)
210+
}
211+
212+
suppressMessages({
213+
sim <- PKPDsim::sim_ode(
214+
ode = model,
215+
parameters = par,
216+
mixture_group = NULL,
217+
covariates = covariates,
218+
n_ind = 1,
219+
int_step_size = int_step_size,
220+
regimen = regimen,
221+
t_obs = t_obs,
222+
obs_type = obs_type,
223+
only_obs = TRUE,
224+
checks = FALSE,
225+
A_init = a_init,
226+
iov_bins = iov_bins,
227+
t_init = t_init,
228+
lagtime = lagtime,
229+
...
230+
)
231+
})
232+
233+
sim$y
234+
}

R/calc_residuals.R

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
#' @param A_init_individual init vector for model state (individual)
1111
#' @param censoring_idx vector with indices for censoring
1212
#' @param data_before_init data.frame with data before initial dose
13-
#'
13+
#' @param nonfixed character vector of non-fixed parameter names
14+
#' @param as_eta character vector of parameters estimated directly as eta
15+
#' @param steady_state_analytic steady state settings (or NULL)
16+
#'
1417
calc_residuals <- function(
1518
obj,
1619
data,
@@ -33,6 +36,9 @@ calc_residuals <- function(
3336
censoring_idx = NULL,
3437
data_before_init = NULL,
3538
ltbs = FALSE,
39+
nonfixed = NULL,
40+
as_eta = c(),
41+
steady_state_analytic = NULL,
3642
...
3743
) {
3844

@@ -100,7 +106,59 @@ calc_residuals <- function(
100106
data_before_init,
101107
weights
102108
)
103-
109+
110+
## Compute proper CWRES and FOCE vcov using the Jacobian (Hooker et al. 2007).
111+
## The Jacobian F = df/deta is computed once via finite differences (2*n_eta
112+
## simulations). Both CWRES and the FOCE Hessian/vcov are derived from F,
113+
## instead of the more expensive numDeriv::hessian computation.
114+
if (!is.null(nonfixed) && length(nonfixed) > 0) {
115+
n_before <- nrow(data_before_init)
116+
# Extract ipred for real observations only (excluding before-init)
117+
ipred_real <- sim_ipred$y[(n_before + 1):length(sim_ipred$y)]
118+
119+
foce_result <- tryCatch(
120+
calc_cwres(
121+
eta_hat = obj$fit$coef,
122+
ipred_raw = ipred_real,
123+
y = data$y,
124+
omega_full = omega_full,
125+
error = error,
126+
obs_type = data$obs_type,
127+
transf = transf,
128+
model = model,
129+
parameters_population = parameters_population,
130+
nonfixed = nonfixed,
131+
as_eta = as_eta,
132+
covariates = covariates,
133+
regimen = regimen,
134+
lagtime = lagtime,
135+
t_obs = data$t,
136+
obs_type_sim = data$obs_type,
137+
int_step_size = int_step_size,
138+
iov_bins = iov_bins,
139+
A_init = A_init_individual,
140+
t_init = t_init,
141+
steady_state_analytic = steady_state_analytic,
142+
...
143+
),
144+
error = function(e) {
145+
warning("CWRES computation failed: ", e$message)
146+
list(cwres = rep(NA_real_, length(data$y)), vcov = NULL)
147+
}
148+
)
149+
150+
# Pad with zeros for before-init observations and apply weights
151+
obj$cwres <- c(rep(0, n_before), foce_result$cwres * weights)
152+
153+
# Set censored observations to NA
154+
if (!is.null(censoring) && any(censoring_idx)) {
155+
obj$cwres[censoring_idx] <- NA_real_
156+
}
157+
158+
# Store FOCE vcov for use in get_map_estimates
159+
obj$foce_vcov <- foce_result$vcov
160+
}
161+
104162
## Add covariates and parameters to obj
105163
if(output_include$covariates && !is.null(covariates)) {
106164
obj$covariates_time <- sim_ipred[!duplicated(sim_ipred$t), names(covariates)]

R/get_map_estimates.R

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -302,14 +302,18 @@ get_map_estimates <- function(
302302
likelihood = like
303303
)
304304
} else {
305+
# When residuals=TRUE, the FOCE Jacobian computed in calc_residuals
306+
# provides both CWRES and the Hessian/vcov, so we can skip the more
307+
# expensive numDeriv::hessian here.
308+
skip_hessian_mle <- skip_hessian || residuals
305309
output <- tryCatch({
306310
fit <- mle_wrapper(
307311
ll_func,
308312
start = omega$eta,
309313
method = method,
310314
optimizer = optimizer,
311315
control = control,
312-
skip_hessian = skip_hessian,
316+
skip_hessian = skip_hessian_mle,
313317
data = list(
314318
data = data,
315319
sim_object = sim_object,
@@ -427,13 +431,22 @@ get_map_estimates <- function(
427431
censoring_idx = censoring_idx,
428432
data_before_init = data_before_init,
429433
ltbs = ltbs,
434+
nonfixed = omega$nonfixed,
435+
as_eta = as_eta,
436+
steady_state_analytic = steady_state_analytic,
430437
...
431438
)
432439
}
433440

434-
## Add variance-covariance matrix to output object
441+
## Add variance-covariance matrix to output object.
442+
## When residuals were computed, the FOCE vcov from the Jacobian is
443+
## preferred over numDeriv::hessian (faster, same FOCE approximation).
444+
vcov_source <- obj$fit$vcov
445+
if (!is.null(obj$foce_vcov)) {
446+
vcov_source <- obj$foce_vcov
447+
}
435448
obj$vcov_full <- get_varcov_matrix(
436-
obj$fit$vcov,
449+
vcov_source,
437450
fallback = omega$full
438451
)
439452
if(inherits(obj$vcov_full, "matrix")) {

R/parse_residuals_from_predictions.R

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ parse_residuals_from_predictions <- function(
3939
obj$res <- (transf(y) - transf(pred))
4040
obj$weights <- c(rep(0, length(data_before_init$t)), weights)
4141
obj$wres <- (obj$res / w_pred) * obj$weights
42-
obj$cwres <- obj$res / sqrt(abs(cov(transf(pred), transf(y)))) * c(rep(0, nrow(data_before_init)), obj$weights)
43-
# Note: in NONMEM CWRES is on the population level, so can't really compare. NONMEM calls this CIWRES, it seems.
42+
# CWRES is computed separately in calc_residuals() using the FOCE
43+
# linearization method (Hooker et al. 2007). Initialize to NA here.
44+
obj$cwres <- rep(NA_real_, length(y))
4445
obj$ires <- (transf(y) - transf(ipred))
4546
obj$iwres <- (obj$ires / w_ipred)
4647
obj$w_ipred <- w_ipred

0 commit comments

Comments
 (0)