--- title: "The rootWishartHD function family: backends, tails, p-values and critical values" author: "Stepan Grinek" output: rmarkdown::html_vignette: toc: true toc_depth: 2 vignette: > %\VignetteIndexEntry{The rootWishartHD function family: backends, tails, p-values and critical values} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.width = 7, fig.height = 4.4, fig.align = "center" ) ``` ## Scope The companion vignette *"Exact and log-scale tail probabilities for Roy's largest root"* concentrates on the two work-horse functions `doubleWishart()` and `doubleWishart_log()`. `rootWishartHD` exports ten numerical functions (plus `rootWishartHD_mpfr_enabled()` for backend diagnostics), and this vignette is a guided tour of the rest: the alternative CDF backends, the robust-Pfaffian stress tool, the applied **p-value** and **critical-value** wrappers, and the complete **single-Wishart** family. The goal is to answer a practical question -- *which function should I call, and what does it cost?* ```{r load} library(rootWishartHD) ``` The double-Wishart functions use Chiani's `(s, m, n)` parametrization. For a MANOVA-type problem with dimension `p`, hypothesis d.f. `q_df` and error d.f. encoded by `m_df`, we use the same mapping as the first vignette: ```{r dsb} dsb <- function(p, m_df, q_df) { s <- q_df mC <- 0.5 * (abs(m_df - s) - 1) df2 <- p - m_df + q_df nC <- 0.5 * (abs(df2 - s) - 1) list(s = s, m = mC, n = nC) } ``` ## A map of the family ```{r family-map, echo = FALSE} fam <- data.frame( `function` = c( "doubleWishart()", "doubleWishart_scaled()", "doubleWishart_log()", "doubleWishart_robustPfaffians()", "doubleWishart_pvalue()", "doubleWishart_qalpha()", "singleWishart_cdf()", "singleWishart_log()", "singleWishart_pvalue()", "singleWishart_qalpha()"), ensemble = c(rep("double", 6), rep("single", 4)), returns = c("CDF", "CDF", "log CDF / log SF", "log CDF / log SF", "p-value", "critical value", "CDF", "log CDF / log SF", "p-value", "critical value"), role = c( "default CDF interface; routes to scaled backend when needed", "scaled Pfaffian CDF backend for ill-conditioned or large cases", "tail-safe log survival and log CDF", "auto Pfaffian backend, stress testing", "applied fast-then-exact upper-tail p-value", "invert the tail for a cutoff", "CDF, single-Wishart largest eigenvalue", "tail-safe log survival, single-Wishart", "applied p-value, single-Wishart", "applied critical value, single-Wishart"), check.names = FALSE, stringsAsFactors = FALSE) knitr::kable(fam, caption = "The main numerical functions exported by rootWishartHD.") ``` Two design ideas recur throughout and are worth stating once: * **Log-scale tails.** A double-precision CDF *saturates*: in moderate dimensions `1 - F` underflows to `0`, so the upper tail is unrecoverable. The `*_log()` functions return `log F` or `log(1 - F)` and stay finite arbitrarily far into the tail. * **Fast-then-exact.** The applied wrappers (`*_pvalue()`, `*_qalpha()`) first try the cheap double CDF and only fall back to the slow arbitrary-precision (multiprecision) path for the points the double path cannot resolve. For large dimensions the wrappers bypass fragile fast-double passes and go directly to log-scale evaluation. Every result is tagged with the `method` actually used. ## Double-Wishart: CDF backends `doubleWishart()` and `doubleWishart_scaled()` both return the CDF `F(theta)` of `theta = lambda / (1 + lambda)`. The first is the default user interface. It uses the historical fast path for small well-conditioned cases and routes to the scaled backend for large dimensions or arbitrary-precision runs. The second exposes the scaled Pfaffian backend directly for diagnostics. On well-conditioned cases the two agree to roughly `1e-6` (the spread lives in the deep lower tail, where the values themselves are tiny): ```{r backends} pr <- dsb(20, 14, 10) # s = 10 theta <- seq(0.5, 0.97, length.out = 6) v0 <- doubleWishart(theta, s = pr$s, m = pr$m, n = pr$n, type = "double", verbose = FALSE) v2 <- doubleWishart_scaled(theta, s = pr$s, m = pr$m, n = pr$n, type = "double", verbose = FALSE) data.frame(theta, doubleWishart = v0, scaled = v2, abs_diff = abs(v0 - v2)) ``` For everyday use, call `doubleWishart()`. Use `doubleWishart_scaled()` only when you want to inspect or force the scaled backend. ## Saturation and log functions The reason the tail functions exist is visible the moment the CDF approaches 1. The numbers below are measured at `s = 40` (`p = 80`); they are shown as a static table because the exact evaluation takes ~40 s for three points and would slow the vignette down. ## The robust-Pfaffian stress tool `doubleWishart_robustPfaffians()` is a hardened entry point for large `s`: it fixes `pf_method = "auto"` (try several Pfaffian backends and take a conservative value), turns on adaptive precision, and always returns on the log scale. For well-behaved cases it is numerically identical to `doubleWishart_log()`; its value is as a fall-back when a single Pfaffian backend produces `CDF > 1` overshoot artifacts that would collapse the tail. ```{r robust} th <- c(0.85, 0.92, 0.97) a <- doubleWishart_log(th, s = pr$s, m = pr$m, n = pr$n, type = "arbitrary", tail = "upper", verbose = FALSE) b <- doubleWishart_robustPfaffians(th, s = pr$s, m = pr$m, n = pr$n, type = "arbitrary", tail = "upper", verbose = FALSE) data.frame(theta = th, log = a, robustPfaffians = b, abs_diff = abs(a - b)) ``` Use `doubleWishart_log()` by default; reach for `doubleWishart_robustPfaffians()` only when you suspect a backend failure at large `s`. ## Applied workflow: p-values and critical values Most users never call the CDF directly. The two applied wrappers cover the common tasks. ### `doubleWishart_pvalue()` Given an observed statistic (as `theta`, or as `lambda` with `input="lambda"`), it returns the upper-tail p-value `P(Theta >= theta)`. It first tries the fast double CDF; if that has saturated it falls back to the exact log-survival path. The `method` attribute records which path produced each value. ```{r pvalue} pv <- doubleWishart_pvalue(c(0.85, 0.92, 0.97), s = pr$s, m = pr$m, n = pr$n, input = "theta", verbose = FALSE) data.frame(theta = c(0.85, 0.92, 0.97), p_value = as.numeric(pv), method = attr(pv, "method")) ``` At small `s` the fast double path resolves everything (`method = "fast_double"`). The important caveat is dimensional: once `s` is large enough that the double CDF saturates even at moderate `theta`, *every* point falls back to `method = "exact"` and the call pays the full multiprecision cost. At `s = 40`, for instance, the fast path is already saturated at `theta = 0.90`, so a p-value there costs on the order of ten seconds, not milliseconds. ### `doubleWishart_qalpha()` The inverse problem: find the critical value `theta_alpha` with `P(Theta >= theta_alpha) = alpha`. It brackets the root on a fast SF grid and then bisects, using the same fast-then-exact evaluator. Round-tripping the result back through `doubleWishart_pvalue()` recovers `alpha`: ```{r qalpha} crit <- sapply(c(0.05, 0.01), function(a) doubleWishart_qalpha(a, s = pr$s, m = pr$m, n = pr$n, return = "theta", verbose = FALSE)) back <- doubleWishart_pvalue(crit, s = pr$s, m = pr$m, n = pr$n, input = "theta", verbose = FALSE) data.frame(alpha = c(0.05, 0.01), theta_crit = crit, p_back = as.numeric(back), rel_err = abs(as.numeric(back) - c(0.05, 0.01)) / c(0.05, 0.01)) ``` ## Single-Wishart family The single-Wishart functions describe the largest eigenvalue of a real Wishart matrix. The argument `x` is on the **eigenvalue scale** and the CDF is `P(lambda_max <= x)`; the parameters are `n_min = min(p, n)` and `n_max = max(p, n)`. The four exported functions mirror the double-Wishart applied set. ```{r single-cdf} nmin <- 5L; nmax <- 10L x <- c(10, 20, 30, 40) F2 <- singleWishart_cdf(x, n_min = nmin, n_max = nmax, type = "double", verbose = FALSE) lsf <- singleWishart_log(x, n_min = nmin, n_max = nmax, type = "arbitrary", tail = "upper", verbose = FALSE) data.frame(x, CDF = F2, CDF_from_logSF = -expm1(lsf), logSF = lsf) ``` `CDF` (fast double) and `CDF_from_logSF` (`1 - exp(logSF)`, exact) agree, which validates the double path in the body of the distribution. The p-value and critical-value wrappers behave exactly like their double-Wishart counterparts, including the `method` tagging and a built-in sanity check that rejects a fast-double CDF if it disagrees wildly with a cheap multiprecision probe: ```{r single-applied} pv <- singleWishart_pvalue(x, n_min = nmin, n_max = nmax, verbose = FALSE) xa <- sapply(c(0.05, 0.01), function(a) singleWishart_qalpha(a, n_min = nmin, n_max = nmax, verbose = FALSE)) xb <- singleWishart_pvalue(xa, n_min = nmin, n_max = nmax, verbose = FALSE) list( pvalues = data.frame(x, p = as.numeric(pv), method = attr(pv, "method")), qalpha = data.frame(alpha = c(0.05, 0.01), x_crit = xa, p_back = as.numeric(xb)) ) ``` ## The benchmark harness The repository ships `benchmark_family.R`, a small sourceable harness that exercises this whole family and tags each call with its timing and the method used. It complements `test_doubleWishartHD_sweep.R` (which focuses on scaling the tail function to large `p`): this one answers "which function, at what cost?". ```r source(system.file("validation", "benchmark_family.R", package = "rootWishartHD")) res <- family_benchmark(which = c("small", "mid")) # s = 10 and s = 20 family_report(res) # compact usability tables # individual probes bench_double_backends(dsb(40, 26, 20)) # backend speed + agreement bench_saturation(dsb(80, 54, 40)) # double-CDF saturation vs logSF (cached) bench_single(5, 10) # single-Wishart family ``` `family_report()` prints four blocks: (1) CDF-backend speed and agreement, (2) the saturation table, (3) the p-value method and the `qalpha` round-trip, and (4) the single-Wishart family. Exact evaluations are cached under `bench_cache/`, so re-runs are fast. ## When to call what ```{r decision, echo = FALSE} dec <- data.frame( goal = c( "CDF in the body of the distribution", "Upper-tail probability that may be tiny", "Observed statistic -> p-value", "Significance level -> critical value", "Suspected Pfaffian-backend failure at large s", "Single-Wishart largest eigenvalue tail"), call = c( "doubleWishart()", "doubleWishart_log(tail = \"upper\")", "doubleWishart_pvalue()", "doubleWishart_qalpha()", "doubleWishart_robustPfaffians()", "singleWishart_pvalue() / singleWishart_qalpha()"), check.names = FALSE, stringsAsFactors = FALSE) knitr::kable(dec, caption = "A quick decision guide for the function family.") ``` A rule of thumb on cost: the double backends are effectively free but saturate; the exact log path is accurate everywhere but grows quickly with `s` (tens of seconds per point near `s = 40`). The applied wrappers hide the switch, but the cost is inherited -- at large `s` they run at exact-path speed. ## References * Chiani, M. (2014). Distribution of the largest root of a matrix for Roy's test in multivariate analysis of variance. *Journal of Multivariate Analysis*, 131, 69-81. \doi{10.1016/j.jmva.2014.04.002} * Chiani, M. (2016). Distribution of the largest eigenvalue for real Wishart and Gaussian random matrices and a simple approximation for the Tracy-Widom distribution. *Journal of Multivariate Analysis*, 143, 480-493. \doi{10.1016/j.jmva.2015.10.007}