This vignette mirrors
sun_kl, but uses the joint hyperspherical parameterization.
It builds a known SUN\((3,3)\), forms a
weighted discrete target from it, and minimises the KL / cross-entropy
with fit_sun33_hs_quad(). The fitting score uses the
dsun_hs_fun() tape (same as
evaluate_sun33_hs_slice()). Soft boxes come from
sun33_hs_bounds(). See sun_kl for the
block-Cholesky analogue.
Because several joint-correlation coordinates can represent nearly
the same SUN law, raw recovery of all 21 free parameters is often weak.
We therefore check that the KL drops toward the value at the true
parameters, that the fitted density matches on a hold-out sample, and
that the sn-style dp summaries are close.
Mild \(U\)-block correlation, independent-SN pair slots, and a few small cross / \(V\) coordinates:
par_true <- c(
xi1 = 0.2, xi2 = -0.1, xi3 = 0.05,
nu1 = 1.0, nu2 = 0.8, nu3 = 1.2,
z21 = 0.15, z31 = 0.05, z32 = 0.10,
z41 = atanh(0.5), z42 = 0, z43 = 0,
z51 = 0, z52 = atanh(0.45), z53 = 0, z54 = 0,
z61 = 0, z62 = 0, z63 = atanh(0.4), z64 = 0.1, z65 = 0.05
)
dp_true <- make_sun_hs_params(par_true)
str(dp_true, max.level = 1)
#> List of 5
#> $ xi : num [1:3] 0.2 -0.1 0.05
#> $ Omega: num [1:3, 1:3] 1 0.119 0.06 0.119 0.64 ...
#> $ Delta: num [1:3, 1:3] 0.5 0.0744 0.025 0 0.445 ...
#> $ tau : num [1:3] 0 0 0
#> $ Gamma: num [1:3, 1:3] 1 0 0.0791 0 1 ...
diag(dp_true$Gamma)
#> [1] 1 1 1
diag(dp_true$Delta)
#> [1] 0.5000000 0.4449845 0.3975113Sample from the true SUN and use the empirical measure as \(p\), with logp from
sn::dsun at the true dp.
library(sn)
#> Loading required package: stats4
#>
#> Attaching package: 'sn'
#> The following object is masked from 'package:admvn':
#>
#> dsun
#> The following object is masked from 'package:stats':
#>
#> sd
set.seed(1)
n <- 600L
x <- sn::rsun(n, dp = dp_true)
w <- rep(1 / n, n)
logp_true <- as.numeric(sn::dsun(x, dp = dp_true, log = TRUE))
quad <- list(x = x, w = w, logp = logp_true)
kl_at_true <- evaluate_sun33_hs_slice(par_true, quad)
kl_at_true
#> kl neg_Elq
#> -2.003944e-10 4.081230e+00Start from a nearly Gaussian SUN matched to the sample moments
(make_sun33_hs_start_from_normal() puts \(C_u\) free coordinates from
cov2cor(Sigma) and optional pair-slot skew).
par_start <- make_sun33_hs_start_from_normal(
mu = colMeans(x),
Sigma = stats::cov(x),
skew_strength = 0.05
)
kl_at_start <- evaluate_sun33_hs_slice(par_start, quad)
kl_at_start
#> kl neg_Elq
#> -0.002758369 4.078471605
sel <- c(
"xi1", "xi2", "xi3", "nu1", "nu2", "nu3",
"z21", "z31", "z32", "z41", "z52", "z63", "z64", "z65"
)
rbind(true = par_true[sel], start = par_start[sel])
#> xi1 xi2 xi3 nu1 nu2 nu3 z21
#> true 0.2000000 -0.1000000 0.0500000 1.0000000 0.8000000 1.200000 0.1500000
#> start 0.5787477 0.2126078 0.4379421 0.9528259 0.7553326 1.182131 0.1557187
#> z31 z32 z41 z52 z63 z64 z65
#> true 0.050000000 0.10000000 0.54930614 0.48470028 0.42364893 0.1 0.05
#> start -0.003901579 0.09544549 0.05004173 0.05004173 0.05004173 0.0 0.00fit <- fit_sun33_hs_quad(
quad = quad,
start = par_start,
mc.cores = 1L,
obj_scale = 1e4,
mvquad_opts = list(n_points = 64L, n_shifts = 2L),
optim_opts = list(maxit = 250L, factr = 1e3, pgtol = 1e-8)
)
print_sun_kl_fit(fit, label = "SUN(3,3) hs KL match")
#> SUN(3,3) hs KL match
#> engine=admvn convergence=1 counts={function=268, gradient=268}
#> message: NEW_X
#> neg_Elq: start=4.07847 fit=4.07124
#> kl: start=-0.00275837 fit=-0.00998706
#> ||grad||_inf=53.5794 (objective scale, obj_scale=10000)
#> gradient:
#> [1] 2.6858804 -6.4615550 8.1391654 4.3277779 1.4927330 0.2993492
#> [7] 9.8808852 9.1892435 -24.5347813 14.0792874 1.7780075 16.2909867
#> [13] 10.1710005 6.5663978 -2.2482062 -7.9006235 -8.4917579 -6.2145540
#> [19] 53.5794470 8.4516663 2.0992298par_hat <- fit$par
names(par_hat) <- names(par_true)
dp_hat <- fit$dp
kl_at_hat <- evaluate_sun33_hs_slice(par_hat, quad)
data.frame(
kl_true = unname(kl_at_true[["kl"]]),
kl_start = unname(kl_at_start[["kl"]]),
kl_hat = unname(kl_at_hat[["kl"]])
)
#> kl_true kl_start kl_hat
#> 1 -2.003944e-10 -0.002758369 -0.00998706
# Hold-out mean log-density under true vs fitted dp
set.seed(2)
x_hold <- sn::rsun(400L, dp = dp_true)
ll_true <- mean(sn::dsun(x_hold, dp = dp_true, log = TRUE))
ll_hat <- mean(sn::dsun(x_hold, dp = dp_hat, log = TRUE))
ll_start <- mean(sn::dsun(x_hold, dp = make_sun_hs_params(par_start), log = TRUE))
c(holdout_ll_true = ll_true, holdout_ll_start = ll_start, holdout_ll_hat = ll_hat)
#> holdout_ll_true holdout_ll_start holdout_ll_hat
#> -3.999308 -4.005194 -4.022040
cmp <- data.frame(
true = par_true,
start = as.numeric(par_start),
hat = as.numeric(par_hat),
abs_err = abs(as.numeric(par_hat) - par_true)
)
print(cmp[sel, ], digits = 4)
#> true start hat abs_err
#> xi1 0.2000 0.578748 0.188638 0.01136
#> xi2 -0.1000 0.212608 0.398441 0.49844
#> xi3 0.0500 0.437942 -0.666514 0.71651
#> nu1 1.0000 0.952826 1.180251 0.18025
#> nu2 0.8000 0.755333 0.968970 0.16897
#> nu3 1.2000 1.182131 1.503775 0.30378
#> z21 0.1500 0.155719 0.368018 0.21802
#> z31 0.0500 -0.003902 0.001431 0.04857
#> z32 0.1000 0.095445 0.116191 0.01619
#> z41 0.5493 0.050042 0.771456 0.22215
#> z52 0.4847 0.050042 -0.673945 1.15865
#> z63 0.4236 0.050042 0.925762 0.50211
#> z64 0.1000 0.000000 -0.082744 0.18274
#> z65 0.0500 0.000000 0.015631 0.03437
list(
xi = rbind(true = dp_true$xi, hat = dp_hat$xi),
Omega_diag = rbind(true = diag(dp_true$Omega), hat = diag(dp_hat$Omega)),
Delta_diag = rbind(true = diag(dp_true$Delta), hat = diag(dp_hat$Delta)),
max_abs_Omega = max(abs(dp_hat$Omega - dp_true$Omega)),
max_abs_Delta = max(abs(dp_hat$Delta - dp_true$Delta)),
max_abs_Gamma = max(abs(dp_hat$Gamma - dp_true$Gamma)),
max_abs_par = max(abs(par_hat - par_true))
)
#> $xi
#> [,1] [,2] [,3]
#> true 0.200000 -0.1000000 0.0500000
#> hat 0.188638 0.3984413 -0.6665142
#>
#> $Omega_diag
#> [,1] [,2] [,3]
#> true 1.000000 0.6400000 1.440000
#> hat 1.392993 0.9389033 2.261341
#>
#> $Delta_diag
#> [,1] [,2] [,3]
#> true 0.5000000 0.4449845 0.3975113
#> hat 0.6477755 -0.5332561 0.6451591
#>
#> $max_abs_Omega
#> [1] 0.8213406
#>
#> $max_abs_Delta
#> [1] 0.9782407
#>
#> $max_abs_Gamma
#> [1] 0.2020149
#>
#> $max_abs_par
#> [1] 1.158646par(mfrow = c(1, 2), mar = c(4, 4, 2, 1))
logq_hat <- as.numeric(sn::dsun(x, dp = dp_hat, log = TRUE))
plot(
logp_true, logq_hat,
xlab = "true log density", ylab = "fitted log density",
main = "Density match on fitting sample"
)
abline(0, 1, col = "red", lty = 2)
plot(
as.vector(dp_true$Omega), as.vector(dp_hat$Omega),
xlab = "true Omega", ylab = "fitted Omega",
main = expression(Omega ~ entries)
)
abline(0, 1, col = "red", lty = 2)What success looks like. The fitted KL should be
much smaller than at the start and close to the KL at
par_true. Hold-out mean log-density under the fitted
dp should nearly match the true SUN. Individual free \(z\) coordinates may still differ when they
are only weakly identified; agreement of the implied density and of
Omega / Delta / Gamma is the more
meaningful recovery check.
Slice the cross-entropy objective \(-\sum_i w_i\log q(x_i)\) one coordinate at a time around the fitted value, and compare the AD partial derivative to a central finite-difference (Richardson) estimate on the same grid.
par0 <- as.numeric(fit$par)
names(par0) <- names(par_true)
w_fit <- as.numeric(fit$quad$w)
w_fit <- w_fit / sum(w_fit)
tape33 <- dsun_hs_fun(
x = as.matrix(fit$quad$x),
par_seed = par0,
weights = w_fit,
n_points = 64L,
n_shifts = 2L,
n_threads = 1L
)
## dsun_hs_fun value = sum w log q; objective = -sum w log q (= KL + const)
obj_ad <- function(p) {
v <- tape33$eval(p, deriv = 0L)$value
if (!is.finite(v)) {
stop("non-finite AD objective at profile point", call. = FALSE)
}
-as.numeric(v)
}
grad_ad <- function(p) {
g <- as.numeric(tape33$eval(p, deriv = 1L)$gradient)
if (any(!is.finite(g))) {
stop("non-finite AD gradient at profile point", call. = FALSE)
}
-g
}
bnd <- sun33_hs_bounds()
col_kl <- "steelblue"
profile_seq <- function(j, n = 11L, half_width = 0.5) {
center <- par0[j]
lo <- bnd$lower[j]
hi <- bnd$upper[j]
hw <- half_width * max(abs(center), 1)
lo_j <- center - hw
hi_j <- center + hw
if (is.finite(lo)) lo_j <- max(lo_j, lo)
if (is.finite(hi)) hi_j <- min(hi_j, hi)
if (!(is.finite(lo_j) && is.finite(hi_j) && lo_j < hi_j)) {
stop(
"Cannot build an interior profile sequence for parameter ", j,
" (", names(par0)[j], ").",
call. = FALSE
)
}
seq(lo_j, hi_j, length.out = n)
}
## Plot obj + FD/AD gradient profiles for a block of parameter indices.
profile_par_block <- function(idx, n_col = 4L) {
idx <- as.integer(idx)
n_panels <- 2L * length(idx)
n_row <- as.integer(ceiling(n_panels / n_col))
op <- par(
mfrow = c(n_row, n_col),
mar = c(3, 3, 1.2, 0.4),
mgp = c(1.6, 0.5, 0)
)
on.exit(par(op), add = TRUE)
for (j in idx) {
lab <- names(par0)[j]
xj_seq <- profile_seq(j)
obj_seq <- vapply(xj_seq, function(xj) {
p <- par0
p[j] <- xj
obj_ad(p)
}, numeric(1))
ad_dobj <- vapply(xj_seq, function(xj) {
p <- par0
p[j] <- xj
grad_ad(p)[j]
}, numeric(1))
## Central FD at the same nodes as AD (Richardson on two step sizes).
lo <- bnd$lower[j]
hi <- bnd$upper[j]
fd_at <- function(xj, h) {
x_lo <- xj - h
x_hi <- xj + h
if (is.finite(lo)) x_lo <- max(x_lo, lo)
if (is.finite(hi)) x_hi <- min(x_hi, hi)
if (!(x_hi > x_lo)) {
stop("central FD step collapsed for ", lab, " at ", format(xj))
}
p_lo <- p_hi <- par0
p_lo[j] <- x_lo
p_hi[j] <- x_hi
(obj_ad(p_hi) - obj_ad(p_lo)) / (x_hi - x_lo)
}
fd_dobj <- vapply(xj_seq, function(xj) {
h1 <- 1e-4 * max(abs(xj), 1)
h2 <- 2 * h1
fd1 <- fd_at(xj, h1)
fd2 <- fd_at(xj, h2)
(4 * fd1 - fd2) / 3
}, numeric(1))
obj_finite <- obj_seq[is.finite(obj_seq)]
if (length(obj_finite)) {
ylim_lo <- min(obj_finite)
ylim_hi <- min(10, max(obj_finite))
if (!(ylim_hi > ylim_lo)) {
ylim_hi <- ylim_lo + 1e-8
}
} else {
ylim_lo <- 0
ylim_hi <- 10
}
plot(
xj_seq, obj_seq,
type = "b", pch = 16, col = col_kl, cex = 0.7,
xlab = lab, ylab = expression(-sum(w * log(q))),
ylim = c(ylim_lo, ylim_hi)
)
abline(v = par0[j], lty = 3, col = "grey60")
plot(
xj_seq, fd_dobj,
type = "b", pch = 4, col = "grey30", cex = 1.4, lwd = 1.5,
xlab = lab, ylab = paste0("d obj / d ", lab)
)
lines(xj_seq, ad_dobj, type = "b", pch = 3, col = col_kl, cex = 0.9, lwd = 1)
abline(h = 0, lty = 3, col = "grey60")
abline(v = par0[j], lty = 3, col = "grey60")
legend(
"topright",
legend = c("FD", "AD"),
col = c("grey30", col_kl), pch = c(4, 3),
pt.cex = c(1.4, 0.9), bty = "n", cex = 0.65
)
}
}SUN(3,3) hs slice profiles for parameters 1-7 (cross-entropy objective; FD vs AD gradient).
SUN(3,3) hs slice profiles for parameters 8-14.
SUN(3,3) hs slice profiles for parameters 15-21.
For each coordinate the left panel is the profiled objective and the right panel overlays the finite-difference slope (grey crosses) and the AD partial (blue pluses). Agreement of the two gradient curves is the gradient check; a vertical line marks the fitted value.