Daily mortality and PM10 in London (2002–2006) often show a near-monotone exposure–response: risk rises with pollution, with a base shape close to \(\sqrt{\mathrm{PM}_{10}}\). The near-monotone Gaussian process (mGP) of Zhang et al. places a spline penalty on the departure from that base model (Ramsay, 1998), while keeping \(f(x_0)=f'(x_0)=0\) so case-crossover strata can absorb the intercept.
This vignette fits a time-stratified case-crossover model with
mgp(pm10, a = 2) and compares it to
iwp(pm10, p = 2).
data("london", package = "adlaplace")
london <- london[!is.na(london$pm10), ]
if (!vignette_full) {
london <- london[london$year == 2003, ]
}
london$sqrt_pm <- sqrt(london$pm10)
range(london$pm10)
#> [1] 8.1 99.6
range(london$tmean, na.rm = TRUE)
#> [1] -1.400507 28.174999PM10 ranges from about 8.1 to 99.6 \(\mu\mathrm{g}/\mathrm{m}^3\).
Strata absorb the intercept (0 + on the RHS). Bare
pre-built term objects are not formula-safe (they become
linear()), so constructors are written inline. Copies of
the terms are kept for later psd() plots.
pm10_iwp <- iwp(
"pm10",
p = 2,
knots = seq(5, 105, by = 20),
ref_value = 25,
init = 0.001
)
pm10_mgp <- mgp("pm10",
a = 2, knots = pm10_iwp[[1]]@knots,
ref_value = pm10_iwp[[1]]@ref_value
)
pm10_tiwp <- iwp("sqrt_pm",
p = 2,
knots = seq(1, 11, by = 2),
ref_value = 5, init = 0.001
)
cc_base <- collect_terms(
dirichlet_multinom(
all,
by = c("year", "month", "dow"),
init = 0.02
) ~ 0 + rh +
iwp(tmean, p = 3, knots = seq(-2, 30, by = 2), ref_value = 20, init = 0.001)
)fit_mgp <- adlaplace(
formula = c(pm10_mgp, cc_base),
data = london,
config = list(
num_threads = vignette_num_threads,
num_shards = fit_num_shards
),
control = list(maxit = fit_maxit, trace = 3, REPORT = 5),
verbose = FALSE,
hessian = vignette_full
)
#> N = 4, M = 5 machine precision = 2.22045e-16
#> At X0, 0 variables are exactly at the bounds
#> At iterate 0 f= 5369.7 |proj g|= 10889
#> At iterate 5 f = 5366.4 |proj g|= 127.26
#> At iterate 10 f = 5366.3 |proj g|= 1741.9
#> At iterate 15 f = 5363.6 |proj g|= 524.47
#> At iterate 20 f = 5350.8 |proj g|= 39.187
#> At iterate 25 f = 5350.8 |proj g|= 33.787
#> At iterate 30 f = 5350.8 |proj g|= 621.41
#> At iterate 35 f = 5344.5 |proj g|= 621.55
#>
#> iterations 38
#> function evaluations 50
#> segments explored during Cauchy searches 38
#> BFGS updates skipped 0
#> active bounds at final generalized Cauchy point 0
#> norm of the final projected gradient 3.1147
#> final function value 5343.78
#>
#> F = 5343.78
#> final value 5343.777651
#> converged
fit_mgp$par_info
#> label mle se mle_internal se_internal
#> 1 rh_linear 6.276465e-04 0.000209456 0.0006276465 0.000209456
#> 2 pm10_mgp 2.203352e-04 NA -8.4203607005 0.982698620
#> 3 all_dirichlet_multinom_sd 4.984877e-05 NA -9.9065167220 0.000000000
#> 4 tmean_iwp 1.063009e-02 NA -4.5440663004 0.412293683
#> init lower upper parscale log
#> 1 0.000000 -Inf Inf 1 FALSE
#> 2 -3.912023 -20.72327 Inf 1 TRUE
#> 3 -3.912023 -Inf Inf 1 TRUE
#> 4 -6.907755 -20.72327 Inf 1 TRUEThe inner solver can stop because the trust radius falls below
stop.trust.radius and still hand that \(\gamma\) to the Laplace approximation. That
solver differentiates the joint log density, so compare its derivative
to first differences along the random-effect coordinate with the largest
gradient at the fitted point.
dens_ptr <- clone_ad_pack_ptr(fit_mgp$ad_pack@ptr)
x_here <- as.numeric(fit_mgp$details$full_parameters)
g_here <- grad(dens_ptr, x_here, negative = FALSE)
n_beta <- as.integer(fit_mgp$ad_pack@sizes[["beta"]])
n_gamma <- as.integer(fit_mgp$ad_pack@sizes[["gamma"]])
gamma_idx <- seq.int(n_beta + 1L, length.out = n_gamma)
Dpar <- gamma_idx[which.max(abs(g_here[gamma_idx]))]
gamma_label <- names(fit_mgp$gamma)[Dpar - n_beta]
Dpar <- length(x_here)
scan_j <- deriv_scan_joint(
dens_ptr,
x = x_here,
index = Dpar,
width = 0.1,
n = 7L,
negative = FALSE
)
grad_at_mid <- (
scan_j$grad[-1L, Dpar] + scan_j$grad[-nrow(scan_j$grad), Dpar]
) / 2
hess_at_mid <- (
scan_j$hess_row[-1L, Dpar] + scan_j$hess_row[-nrow(scan_j$hess_row), Dpar]
) / 2
data.frame(
gamma = gamma_label,
max_abs_diff = max(abs(grad_at_mid - scan_j$fd_value)),
max_abs_diff_hess = max(abs(hess_at_mid - scan_j$fd_grad[, Dpar]))
)
#> gamma max_abs_diff max_abs_diff_hess
#> 1 pm10_mgp_k5 0.001303743 0.002607486
oldpar <- par(no.readonly = TRUE)
par(mfrow = c(1, 3), mar = c(3, 3, 2, 0.5), mgp = c(1.6, 0.5, 0))
plot(
scan_j$grid, scan_j$value,
type = "b",
xlab = gamma_label, ylab = "joint log density"
)
plot(
scan_j$grid, scan_j$grad[, Dpar],
type = "l",
xlab = gamma_label, ylab = "derivative"
)
points(scan_j$mid, scan_j$fd_value, pch = 16)
legend(
"topright",
legend = c("AD", "first difference"),
lty = c(1, NA), pch = c(NA, 16), bty = "n"
)
plot(
scan_j$grid, scan_j$hess_row[, Dpar],
type = "l",
xlab = gamma_label, ylab = "second derivative",
ylim = range(
c(scan_j$hess_row[, Dpar], scan_j$fd_grad[, Dpar]),
na.rm = TRUE
)
)
points(scan_j$mid, scan_j$fd_grad[, Dpar], pch = 16)The outer objective is the Laplace approximation to the log likelihood. The same first-difference check, along the PM10 mGP standard deviation, compares that profile derivative to the log likelihood itself.
x_outer <- fit_mgp$par_info$mle_internal
Dpar_ll <- match("pm10_mgp", fit_mgp$par_info$label)
scan_label <- fit_mgp$par_info$label[Dpar_ll]
scan_ll <- deriv_scan_laplace(
fit_mgp$ad_pack,
x = x_outer,
index = Dpar_ll,
width = 0.2,
n = 5L,
config = fit_mgp$config,
gamma = as.numeric(fit_mgp$gamma),
control = fit_mgp$control_inner
)
grad_ll_mid <- (
scan_ll$grad[-1L, Dpar_ll] + scan_ll$grad[-nrow(scan_ll$grad), Dpar_ll]
) / 2
data.frame(
parameter = scan_label,
max_abs_diff = max(abs(grad_ll_mid - scan_ll$fd_value))
)
#> parameter max_abs_diff
#> 1 pm10_mgp 0.002090781
oldpar <- par(no.readonly = TRUE)
par(mfrow = c(1, 2), mar = c(3, 3, 2, 0.5), mgp = c(1.6, 0.5, 0))
plot(
scan_ll$grid, scan_ll$value,
type = "b",
xlab = scan_label, ylab = "negative log likelihood"
)
plot(
scan_ll$grid, scan_ll$grad[, Dpar_ll],
type = "l",
xlab = scan_label, ylab = "derivative"
)
points(scan_ll$mid, scan_ll$fd_value, pch = 16)
legend(
"topright",
legend = c("AD", "first difference"),
lty = c(1, NA), pch = c(NA, 16), bty = "n"
)fit_iwp <- adlaplace(
formula = c(pm10_iwp, cc_base),
data = london,
config = list(
num_threads = vignette_num_threads,
num_shards = fit_num_shards
),
control = list(maxit = fit_maxit, trace = 3, REPORT = 5),
verbose = FALSE,
hessian = vignette_full
)
#> N = 4, M = 5 machine precision = 2.22045e-16
#> At X0, 0 variables are exactly at the bounds
#> At iterate 0 f= 5351.6 |proj g|= 10561
#> At iterate 5 f = 5348.6 |proj g|= 122.43
#> At iterate 10 f = 5348.5 |proj g|= 1673.1
#> At iterate 15 f = 5345.8 |proj g|= 454.54
#> At iterate 20 f = 5340.8 |proj g|= 45.111
#> At iterate 25 f = 5340.8 |proj g|= 188.79
#> At iterate 30 f = 5335.7 |proj g|= 8809.7
#> ys=-1.336e+00 -gs= 6.152e-01, BFGS update SKIPPED
#> At iterate 35 f = 5329.4 |proj g|= 43.464
#>
#> iterations 38
#> function evaluations 57
#> segments explored during Cauchy searches 38
#> BFGS updates skipped 1
#> active bounds at final generalized Cauchy point 0
#> norm of the final projected gradient 0.0956055
#> final function value 5329.4
#>
#> F = 5329.4
#> final value 5329.397015
#> converged
fit_iwp$par_info
#> label mle se mle_internal se_internal
#> 1 rh_linear 0.000605080 0.0002271874 0.00060508 0.0002271874
#> 2 pm10_iwp 0.001009738 NA -6.89806416 0.0000000000
#> 3 all_dirichlet_multinom_sd 0.016985913 NA -4.07537092 0.1158929861
#> 4 tmean_iwp 0.010380336 NA -4.56784204 0.4108226114
#> init lower upper parscale log
#> 1 0.000000 -Inf Inf 1 FALSE
#> 2 -6.907755 -20.72327 Inf 1 TRUE
#> 3 -3.912023 -Inf Inf 1 TRUE
#> 4 -6.907755 -20.72327 Inf 1 TRUEfit_tiwp <- adlaplace(
formula = c(pm10_tiwp, cc_base),
data = london,
config = list(
num_threads = vignette_num_threads,
num_shards = fit_num_shards
),
control = list(maxit = fit_maxit, trace = 3, REPORT = 5),
verbose = FALSE,
hessian = vignette_full
)
#> N = 4, M = 5 machine precision = 2.22045e-16
#> At X0, 0 variables are exactly at the bounds
#> At iterate 0 f= 5351.7 |proj g|= 10498
#> At iterate 5 f = 5348.7 |proj g|= 119.2
#> At iterate 10 f = 5348.6 |proj g|= 1635.6
#> At iterate 15 f = 5345.7 |proj g|= 217.86
#> At iterate 20 f = 5340.7 |proj g|= 1.209
#>
#> iterations 21
#> function evaluations 27
#> segments explored during Cauchy searches 21
#> BFGS updates skipped 0
#> active bounds at final generalized Cauchy point 0
#> norm of the final projected gradient 1.20971
#> final function value 5340.67
#>
#> F = 5340.67
#> final value 5340.667903
#> converged
fit_tiwp$par_info
#> label mle se mle_internal
#> 1 rh_linear 0.0006130639 0.0002088531 0.0006130639
#> 2 sqrt_pm_iwp 0.0010012734 NA -6.9064826600
#> 3 all_dirichlet_multinom_sd 0.0025869677 NA -5.9572688641
#> 4 tmean_iwp 0.0089191784 NA -4.7195514484
#> se_internal init lower upper parscale log
#> 1 0.0002088531 0.000000 -Inf Inf 1 FALSE
#> 2 0.0000000000 -6.907755 -20.72327 Inf 1 TRUE
#> 3 0.0000000000 -3.912023 -Inf Inf 1 TRUE
#> 4 0.3679125344 -6.907755 -20.72327 Inf 1 TRUEsim_random() needs fit$gamma and
fit$details$hessian$chol_inner. Those are always filled by
the post-fit Laplace evaluation, but the curves below are full-build
only (abbreviated R CMD check stops after
par_info). The grey histogram is the distribution of PM10
in the case-crossover data.
num_sim <- 200L
# Pass new_x explicitly: pretty() can step outside the knot range for some n.
pm10_grid <- data.frame(pm10 = seq(8, 100, length.out = 81L))
set.seed(1)
sim_mgp <- sim_random(
"pm10",
fit = fit_mgp,
new_x = pm10_grid,
num_sim = num_sim,
transform = exp
)
set.seed(1)
sim_iwp <- sim_random(
"pm10",
fit = fit_iwp,
new_x = pm10_grid,
num_sim = num_sim,
transform = exp
)
sim_tiwp <- sim_random(
"sqrt_pm",
fit = fit_tiwp,
new_x = data.frame(sqrt_pm = sqrt(pm10_grid$pm10)),
num_sim = num_sim,
transform = exp
)
rr_ylim <- range(sim_mgp$pointwise, sim_iwp$pointwise)
oldpar <- par(no.readonly = TRUE)
par(mar = c(3, 3, 1, 1), bty = "l", mgp = c(1.8, 0.5, 0))
plot(
NA, NA,
xlim = range(pm10_grid$pm10), ylim = rr_ylim,
xlab = expression(PM[10]), ylab = "RR"
)
hx <- london$pm10
hx <- hx[is.finite(hx) & hx >= min(pm10_grid$pm10) & hx <= max(pm10_grid$pm10)]
if (length(hx) > 10L) {
hb <- hist(hx, breaks = 30, plot = FALSE)
dens <- hb$counts / max(hb$counts)
y0 <- rr_ylim[1]
y1 <- y0 + 0.12 * diff(rr_ylim)
rect(
hb$breaks[-length(hb$breaks)], y0,
hb$breaks[-1L], y0 + dens * (y1 - y0),
col = adjustcolor("grey50", alpha.f = 0.25),
border = NA
)
}
lines(sim_mgp$x, sim_mgp$pointwise[, "0.5"], lwd = 2, col = "#1b4f72")
matlines(sim_mgp$x, sim_mgp$pointwise[, c("0.1", "0.9")], lty = 2, col = "#1b4f72")
lines(sim_iwp$x, sim_iwp$pointwise[, "0.5"], lwd = 2, col = "#b03a2e")
matlines(sim_iwp$x, sim_iwp$pointwise[, c("0.1", "0.9")], lty = 2, col = "#b03a2e")
matlines(sim_tiwp$x^2, sim_tiwp$pointwise[, c("0.1", "0.5", "0.9")],
lty = c(2, 1, 2), col = "green"
)
legend(
"topleft",
legend = c("mGP (a = 2)", "IWP", "TIWP"),
col = c("#1b4f72", "#b03a2e", "green"),
lwd = 2,
bty = "n"
)fit_a <- function(a) {
adlaplace(
c(
mgp("pm10",
a = a, knots = pm10_mgp[[1]]@knots,
ref_value = pm10_mgp[[1]]@ref_value
),
cc_base
),
data = london,
config = list(num_threads = vignette_num_threads, num_shards = fit_num_shards),
control = list(maxit = fit_maxit),
verbose = FALSE,
hessian = FALSE
)
}
fits <- lapply(c(1, 2, Inf), fit_a)
#> Warning in adlaplace::inner_opt(parameters = x_num, gamma = gamma, control =
#> control_inner, : inner_opt: non-positive or non-finite D; half_log_det not
#> computed
#> Warning: Laplace objective was not finite; retrying from gamma = 0
#> Warning: inner_opt status is Radius of trust region is less than
#> stop.trust.radius; the Laplace approximation used this inner solution anyway
logliks <- vapply(fits, function(f) as.numeric(stats::logLik(f)), numeric(1))
data.frame(a = c(1, 2, Inf), logLik = logliks)
#> a logLik
#> 1 1 -5350.276
#> 2 2 -5343.772
#> 3 Inf -5343.886
as.numeric(stats::logLik(fit_mgp))
#> [1] -5343.778sd_col <- if ("mle" %in% names(fit_mgp$par_info)) "mle" else "init"
sd_mgp <- fit_mgp$par_info[[sd_col]][fit_mgp$par_info$label == "pm10_mgp"]
sd_iwp <- fit_iwp$par_info[[sd_col]][fit_iwp$par_info$label == "pm10_iwp"]
h <- 5
x_grid <- seq(8, 90, length.out = 40)
psd_mgp_x <- psd(pm10_mgp$pm10_mgp, h = h, x = x_grid, sd = sd_mgp)
psd_iwp_x <- rep(psd(pm10_iwp$pm10_iwp, h = h, sd = sd_iwp), length(x_grid))
oldpar <- par(no.readonly = TRUE)
par(mar = c(3, 3, 1.5, 1), bty = "l", mgp = c(1.8, 0.5, 0))
plot(
x_grid, psd_mgp_x,
type = "l", lwd = 2, col = "#1b4f72",
xlab = expression(PM[10]), ylab = expression(sigma[x](5)),
ylim = range(psd_mgp_x, psd_iwp_x)
)
lines(x_grid, psd_iwp_x, lwd = 2, col = "#b03a2e")
legend("topleft", c("mGP", "IWP"), col = c("#1b4f72", "#b03a2e"), lwd = 2, bty = "n")The mGP predictive SD depends on \(x\) (nonstationary), while the IWP predictive SD is constant.