This vignette mirrors
sun_mle, but uses the joint hyperspherical parameterization
of make_sun_hs_params() / dsun_hs_fun().
Orthant probabilities in dimension \(\le
3\) use specialized bivariate/trivariate CDF routines, and their
gradients are analytic. We compare stats::optim() and the
C++ trust-region driver sun_mle(), and check agreement with
sn::dsun at the fitted value. The block-Cholesky API is
unchanged; see the sun_mle vignette for that path.
A SUN\((3,3)\) observation is \(Y = U \mid V > 0\) with \(U,V\in\mathbb{R}^3\). Writing \(U_\star\) for the standardized latent (unit variances) and \(D_u=\operatorname{diag}(\nu)\) with \(\nu_j>0\) the marginal standard deviations,
\[ \begin{bmatrix} U_\star \\ V \end{bmatrix} \sim \mathcal{N}(0, J), \qquad U = \xi + D_u U_\star, \]
where the joint correlation \(J\) of \((U_\star,V)\in\mathbb{R}^6\) is built from a unit-row lower Cholesky factor \(B\):
\[ J = B B^\top. \]
Write \(t_{ij}=\tanh(z_{ij})\in(-1,1)\). Rows of \(B\) are filled left to right for \(i=2,\ldots,6\) and \(j=1,\ldots,i-1\):
\[ B_{ij} = t_{ij}\sqrt{1-\sum_{k<j} B_{ik}^2}, \qquad B_{ii} = \sqrt{1-\sum_{k<i} B_{ik}^2}, \qquad B_{11}=1. \]
Every row has unit Euclidean norm for any \(z\in\mathbb{R}^{15}\), so \(J=BB^\top\) is always a correlation matrix. The coordinate \(t_{ij}\) is the correlation of variable \(i\) with the \(j\)-th orthogonalized direction of the preceding variables: leading entries are ordinary correlations, and later entries behave like partial correlations.
Fill order. Free slots sit in the strict lower triangle of the \(6\times 6\) factor (row-major). Writing the names of the free coordinates:
\[ B = \begin{bmatrix} 1 & 0 & 0 & 0 & 0 & 0 \\ z_{21} & \cdot & 0 & 0 & 0 & 0 \\ z_{31} & z_{32} & \cdot & 0 & 0 & 0 \\ z_{41} & z_{42} & z_{43} & \cdot & 0 & 0 \\ z_{51} & z_{52} & z_{53} & z_{54} & \cdot & 0 \\ z_{61} & z_{62} & z_{63} & z_{64} & z_{65} & \cdot \end{bmatrix} \]
(dots mark the residual diagonal square-roots). In the length-21 parameter vector these fifteen \(z\) follow \(\xi\) and \(\nu\).
Location / scales. \(\xi=(\xi_1,\xi_2,\xi_3)^\top\) and \(\nu_j>0\) occupy the same slots as in
the block-Cholesky layout, so sun_mle() works on
hyperspherical tapes without modification.
Independent skew-normals. Only the pair slots \(z_{4,1},z_{5,2},z_{6,3}=\operatorname{atanh}(d_i)\) nonzero yields \(J=\begin{bmatrix}I&D\\D&I\end{bmatrix}\) with \(D=\operatorname{diag}(d)\). All \(z=0\) recovers a Gaussian.
Soft boxes. sun33_hs_bounds() keeps
pair \(|t|\le 0.8\) and other \(|t|\le 0.5\). An \(\varepsilon=10^{-6}\) floor on residual
row-norm squares keeps \(J\)
numerically PD near the boundary.
\[ \xi_1,\xi_2,\xi_3,\; \nu_1,\nu_2,\nu_3,\; z_{21},z_{31},z_{32},z_{41},\ldots,z_{65}. \]
true_params <- c(
xi1 = 0, xi2 = 0, xi3 = 0,
nu1 = 1, nu2 = 1, nu3 = 1,
z21 = 0.2, z31 = 0.1, z32 = 0.15,
z41 = atanh(0.35), z42 = 0.05, z43 = -0.05,
z51 = 0.05, z52 = atanh(0.3), z53 = 0.05, z54 = 0.1,
z61 = -0.05, z62 = 0.05, z63 = atanh(0.25), z64 = 0.1, z65 = 0.05
)
true_sun_params <- make_sun_hs_params(true_params)
str(true_sun_params, max.level = 1)
#> List of 5
#> $ xi : num [1:3] 0 0 0
#> $ Omega: num [1:3, 1:3] 1 0.1974 0.0997 0.1974 1 ...
#> $ Delta: num [1:3, 1:3] 0.35 0.11496 -0.00417 0.04996 0.30359 ...
#> $ tau : num [1:3] 0 0 0
#> $ Gamma: num [1:3, 1:3] 1 0.1179 0.0631 0.1179 1 ...library(sn)
set.seed(0)
n <- 500
ysim <- sn::rsun(n, dp = true_sun_params)
colnames(ysim) <- paste0("Y", 1:3)
head(ysim)
#> Y1 Y2 Y3
#> [1,] 1.7883370 0.4081508 1.59061403
#> [2,] 1.1074220 0.9592382 -0.70678666
#> [3,] -0.8842272 -0.3764776 -0.02136006
#> [4,] 2.2899684 1.1767293 -0.26603871
#> [5,] -0.8308968 -0.3323442 -0.30570815
#> [6,] -0.2338628 0.3750492 -0.74566247dsun_hs_fun() fixes the data and builds per-observation
shard tapes. For SUN\((3,3)\) the
orthant CDFs use the specialized \(d\le
3\) path (QMC settings are unused for those values).
# Unused for d<=3 specialized CDF values; kept for API compatibility.
qmc_n_points <- 64L
qmc_n_shifts <- 2L
build_time <- system.time({
tape <- dsun_hs_fun(
ysim,
true_params,
n_points = qmc_n_points,
n_shifts = qmc_n_shifts,
n_threads = 2L
)
})["elapsed"]
check <- tape$eval(true_params, log = TRUE, deriv = 1L)
sn_ll <- sum(sn::dsun(ysim, dp = true_sun_params, log = TRUE))
c(
sn = sn_ll,
admvn = check$value,
build_sec = unname(build_time),
n_threads = tape$n_threads
)
#> sn admvn build_sec n_threads
#> -2068.704 -2068.704 0.651 2.000optim() and an analytic gradienttape$optim_fns() returns cached
fn/gr that share one deriv = 1
evaluation per new parameter value.
start_params <- make_sun33_hs_start_from_normal(
mu = colMeans(ysim),
Sigma = stats::cov(ysim),
skew_strength = 0.15
)
bnd <- sun33_hs_bounds()
lower <- bnd$lower
upper <- bnd$upper
# Keep nu floors a bit above the package default for this demo.
lower[4:6] <- pmax(lower[4:6], 1e-3)
parscale <- pmax(abs(as.numeric(start_params)), 0.1)
optim_ctrl <- list(
fnscale = -1,
maxit = 200,
parscale = parscale
)
obj <- tape$optim_fns()sun_mle() (trustOptim)sun_mle() runs a quasi-Newton trust-region loop entirely
in C++ (no R fn/gr round-trips). The nu parameters are
optimized on the log scale.
sn::dsunsn_ll_at <- function(par) {
sum(sn::dsun(ysim, dp = make_sun_hs_params(par), log = TRUE))
}
ad_ll_at <- function(par) {
tape$eval(par, log = TRUE, deriv = 0L)$value
}
compare <- data.frame(
method = "ad_optim",
sn_loglik = sn_ll_at(fit_ad$par),
admvn_loglik = ad_ll_at(fit_ad$par),
iterations = unname(fit_ad$counts[["gradient"]]),
elapsed_sec = unname(fit_ad_time["elapsed"]),
stringsAsFactors = FALSE
)
if (exists("fit_trust")) {
compare <- rbind(
data.frame(
method = "ad_trustOptim",
sn_loglik = sn_ll_at(fit_trust$par),
admvn_loglik = ad_ll_at(fit_trust$par),
iterations = as.numeric(fit_trust$iterations),
elapsed_sec = unname(fit_trust_time["elapsed"]),
stringsAsFactors = FALSE
),
compare
)
}
rownames(compare) <- NULL
compare
#> method sn_loglik admvn_loglik iterations elapsed_sec
#> 1 ad_trustOptim -2061.182 -2061.181 200 25.178
#> 2 ad_optim -2063.988 -2063.988 32 1.757sn_loglik and admvn_loglik are \(\sum_i \log f(y_i;\hat\theta)\) at each
method’s reported parameter estimate. With the specialized \(d\le 3\) CDF, the two log-densities agree
closely.
Compare the true free parameters to the start and the MLE (preferring the trustOptim fit when available). Approximate standard errors use a central-difference diagonal of the observed information (\(\operatorname{se}_j \approx 1/\sqrt{-H_{jj}}\)). (The AD Hessian pattern currently under-reports curvature for some blocks, so finite differences are used for this table.)
par_hat <- as.numeric(if (exists("fit_trust")) fit_trust$par else fit_ad$par)
names(par_hat) <- names(true_params)
ll0 <- function(p) tape$eval(p, log = TRUE, deriv = 0L)$value
f0 <- ll0(par_hat)
se <- vapply(seq_along(par_hat), function(j) {
h <- pmax(1e-5, 1e-4 * abs(par_hat[j]))
p_up <- p_dn <- par_hat
p_up[j] <- par_hat[j] + h
p_dn[j] <- par_hat[j] - h
if (j %in% 4:6) {
if (p_dn[j] <= 1e-8) p_dn[j] <- max(par_hat[j] * 0.5, 1e-6)
g_up <- (ll0(p_up) - f0) / (p_up[j] - par_hat[j])
g_dn <- (f0 - ll0(p_dn)) / (par_hat[j] - p_dn[j])
Hjj <- (g_up - g_dn) / (0.5 * ((p_up[j] - par_hat[j]) + (par_hat[j] - p_dn[j])))
} else {
Hjj <- (ll0(p_up) - 2 * f0 + ll0(p_dn)) / h^2
}
if (is.finite(Hjj) && Hjj < 0) sqrt(-1 / Hjj) else NA_real_
}, numeric(1))
z_score <- (par_hat - as.numeric(true_params)) / se
recovery <- data.frame(
true = as.numeric(true_params),
start = as.numeric(start_params),
mle = as.numeric(par_hat),
se = se,
z = z_score,
row.names = names(true_params)
)
knitr::kable(recovery, digits = 3)| true | start | mle | se | z | |
|---|---|---|---|---|---|
| xi1 | 0.000 | 0.222 | -0.209 | 0.041 | -5.058 |
| xi2 | 0.000 | 0.378 | 0.143 | 0.043 | 3.306 |
| xi3 | 0.000 | 0.150 | -0.289 | 0.042 | -6.926 |
| nu1 | 1.000 | 0.961 | 1.183 | 0.036 | 5.023 |
| nu2 | 1.000 | 0.997 | 1.054 | 0.032 | 1.659 |
| nu3 | 1.000 | 0.950 | 1.008 | 0.030 | 0.269 |
| z21 | 0.200 | 0.187 | 0.048 | 0.047 | -3.261 |
| z31 | 0.100 | 0.126 | 0.127 | 0.047 | 0.567 |
| z32 | 0.150 | 0.118 | 0.209 | 0.043 | 1.361 |
| z41 | 0.365 | 0.151 | 0.930 | 0.061 | 9.305 |
| z42 | 0.050 | 0.000 | -0.132 | 0.081 | -2.238 |
| z43 | -0.050 | 0.000 | 0.252 | 0.084 | 3.596 |
| z51 | 0.050 | 0.000 | -0.212 | 0.058 | -4.500 |
| z52 | 0.310 | 0.151 | 0.463 | 0.058 | 2.643 |
| z53 | 0.050 | 0.000 | 0.422 | 0.066 | 5.613 |
| z54 | 0.100 | 0.000 | 0.750 | 0.319 | 2.035 |
| z61 | -0.050 | 0.000 | 0.299 | 0.149 | 2.339 |
| z62 | 0.050 | 0.000 | -0.055 | 0.107 | -0.975 |
| z63 | 0.255 | 0.151 | 0.116 | 0.110 | -1.275 |
| z64 | 0.100 | 0.000 | 0.946 | 0.475 | 1.779 |
| z65 | 0.050 | 0.000 | -0.010 | 0.515 | -0.117 |
Free-parameter recovery is often weak: several joint-correlation coordinates can represent nearly the same SUN law, so individual late-row \(z\) entries need not match the truth even when the fitted density does (see the log-likelihood comparison above). Location, scales, and early-row / pair-slot \(z\) are usually better behaved; wide SEs flag the weakly identified slots.
Compare analytic/CppAD partials to central finite
differences along a few one-dimensional slices through \(\hat\theta\).
par_hat <- as.numeric(if (exists("fit_trust")) fit_trust$par else fit_ad$par)
par_names <- names(true_params)
if (is.null(par_names)) {
par_names <- paste0("p", seq_along(par_hat))
}
names(par_hat) <- par_names
idx <- match(c("xi1", "nu1", "z41", "z21"), par_names)
ll <- function(p) tape$eval(p, log = TRUE, deriv = 0L)$value
ad_grad_j <- function(p, j) tape$eval(p, log = TRUE, deriv = 1L)$gradient[j]
fd_grad_j <- function(p, j, h) {
p_up <- p
p_dn <- p
p_up[j] <- p[j] + h
p_dn[j] <- p[j] - h
(ll(p_up) - ll(p_dn)) / (2 * h)
}
op <- par(mfrow = c(2, 2), mar = c(3.2, 3.2, 2.2, 0.8), oma = c(0, 0, 1.5, 0))
on.exit(par(op), add = TRUE)
for (j in idx) {
center <- par_hat[j]
span <- pmax(0.15 * abs(center), 0.05)
grid <- center + seq(-span, span, length.out = 9L)
if (par_names[j] %in% c("nu1", "nu2", "nu3")) {
grid <- grid[grid > 1e-3]
}
ad_vals <- numeric(length(grid))
fd_vals <- numeric(length(grid))
for (k in seq_along(grid)) {
p <- par_hat
p[j] <- grid[k]
h <- pmax(1e-5, 1e-4 * abs(grid[k]))
ad_vals[k] <- ad_grad_j(p, j)
fd_vals[k] <- fd_grad_j(p, j, h)
}
ylim <- range(c(ad_vals, fd_vals), finite = TRUE)
plot(
grid, ad_vals,
type = "l", lwd = 2, col = "#1b4f72",
xlab = par_names[j], ylab = expression(partialdiff * ell / partialdiff * theta),
main = par_names[j],
ylim = ylim
)
lines(grid, fd_vals, lwd = 2, col = "#b03a2e", lty = 2)
abline(v = center, col = "gray50", lty = 3)
}legend(
"topleft",
legend = c("AD", "finite diff"),
col = c("#1b4f72", "#b03a2e"),
lty = c(1, 2), lwd = 2, bty = "n", cex = 0.8
)