SUN(3,3) MLE with joint hyperspherical parameterization

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.

Parameterization

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. \]

Unit-row Cholesky from free \(z\)

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.

Parameter order

\[ \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 ...

Simulation

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.74566247

Build a reusable log-likelihood tape

dsun_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.000

MLE with optim() and an analytic gradient

tape$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()
fit_ad_time <- system.time({
  fit_ad <- optim(
    start_params,
    fn = obj$fn,
    gr = obj$gr,
    method = "L-BFGS-B",
    lower = lower,
    upper = upper,
    control = optim_ctrl
  )
})

fit_ad$convergence
#> [1] 0
fit_ad$value
#> [1] -2063.988
unname(fit_ad_time["elapsed"])
#> [1] 1.757

MLE with C++ 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.

fit_trust_time <- system.time({
  fit_trust <- sun_mle(
    tape,
    start_params,
    control = list(maxit = 200L, report.freq = 0L, report.level = 0L)
  )
})
fit_trust$value
#> [1] -2061.181
fit_trust$iterations
#> [1] 200
unname(fit_trust_time["elapsed"])
#> [1] 25.178

Comparison with sn::dsun

sn_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.757

sn_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.

Parameter recovery

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.

Gradient check at the fitted MLE

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
)