Did the dependence structure change between two periods? The likelihood-ratio statistic is Box’s M, and its null distribution has traditionally been handled by asymptotic approximations that are poor exactly where the question is interesting: small samples and moderate dimension.
The statistic vanishes when nothing differs, and it is invariant to a common change of basis:
cov_M(XA, XA)
#> [1] 0
A <- matrix(c(2, 0.3, -1, 0.1, 1.4, 0.2, 0, 0.5, 1), 3, 3)
c(original = cov_M(XA, XB), transformed = cov_M(XA %*% A, XB %*% A))
#> original transformed
#> 35.19953 35.19953That invariance is the point. Because the null law does not depend on the unknown common covariance, it can be calibrated once at the identity and used at every covariance, with no nuisance parameter to estimate.
cov_pexact evaluates the null tail by inverting the
exact characteristic function, so the test is exact rather than
approximate. It agrees with simulation from the covariance-free null,
which is an independent route:
set.seed(3)
null <- cov_null(p = 3, nA = 40, nB = 40, B = 20000, seed = 5L)
q <- unname(quantile(null, c(0.5, 0.9)))
rbind(simulated_tail = c(0.5, 0.1),
exact_tail = vapply(q, function(m) cov_pexact(m, 39, 39, 3), 0))
#> [,1] [,2]
#> simulated_tail 0.5000000 0.10000000
#> exact_tail 0.5013875 0.09727948Applied to the data above, the scale change in one coordinate is detected:
and two samples from the same law are not:
The same machinery gives a log-domain differential network, \(D = \log \mathrm{cov}(X_B) - \log \mathrm{cov}(X_A)\). It is symmetric, antisymmetric under swapping the samples, and inversion-invariant, so it says the same thing whether you think in covariances or precisions:
D <- cov_logdiff(XA, XB)
round(D, 4)
#> [,1] [,2] [,3]
#> [1,] 0.3851 0.0774 -0.0867
#> [2,] 0.0774 1.2499 0.1094
#> [3,] -0.0867 0.1094 -0.9849
c(antisymmetry = max(abs(D + cov_logdiff(XB, XA))), symmetry = max(abs(D - t(D))))
#> antisymmetry symmetry
#> 0.000000e+00 1.387779e-17A max-type statistic in the style of Cai, Liu and Xia is also provided for the sparse alternative:
Everything above runs in a shared C back-end with its own random number generator, so results do not depend on R’s RNG state, and the same sources are bound from Python.